# Isolation Pod — Cross-layer protocol | Item | Value | |---|---| | Plugin / core version | `0.2.0` | | Sidecar protocol | `1` | | SQLite schema | `1` | | Host build | `2` | | Applies to | DSH `0.2.1+` | This document is the frozen contract between the Client half, the Host half, and the Rust sidecar. A change that is not backward compatible must bump the protocol version (and the schema version when persistence changes) and must be reflected in [`README.md`](../README.md), [`ARCHITECTURE.md`](ARCHITECTURE.md), and [`MIGRATION.md`](MIGRATION.md). ## 1. Sidecar transport - The Host spawns `isolation-pod-core` with piped stdio and speaks **newline-delimited JSON** (NDJSON) over stdin/stdout. - stdout carries protocol frames only. All logs go to stderr (and the rotating `core.log` under the plugin data directory). - Encoding is UTF-8. One JSON value per line; no pretty printing. - Limits: a request line is at most **1 MiB**; a response line is at most **4 MiB**. - Frames: ```json {"id":"","method":"","params":{}} {"id":"","result":{}} {"id":"","error":{"code":"STABLE_CODE","message":"text","data":null}} {"method":"event","params":{"kind":"...","...": "..."}} ``` - The sidecar does not open a network listener. It exits after `shutdown`, or when stdin reaches EOF (the parent process is gone). - The Host discovers the binary in this order: 1. `ISOLATION_POD_CORE_BIN` environment variable; 2. `lib/bin/-/isolation-pod-core[.exe]`; 3. package `bin` metadata, when present. ## 2. Handshake and lifecycle `initialize` params: ```json { "protocol": 1, "pluginVersion": "0.2.0", "dbPath": "/isolation-pod/pod.db", "legacyJsonPath": "/isolation-pod.json", "hostPid": 12345, "platform": "win32" } ``` Result: ```json { "protocol": 1, "coreVersion": "0.2.0", "schemaVersion": 1, "dbPath": "...", "config": { "sandboxRoot": "", "...": "..." }, "tasks": [ "TaskSummary" ], "exports": [ "ExportRecord" ], "diagnostics": [ { "at": 0, "level": "info", "message": "..." } ] } ``` - `initialize` is mandatory before any state method and is idempotent within one process. - A protocol or schema mismatch answers `PROTOCOL_MISMATCH` / `SCHEMA_MISMATCH`; the Host refuses to start tasks but keeps the plugin and diagnostics loaded. - On startup the sidecar: - marks `queued`, `starting`, and `running` tasks as `interrupted` (`error = "宿主重启,任务已中断"`); - marks running exports as `interrupted`; - clears all task leases from the previous host process; - runs the one-time v0.1.3 JSON migration when applicable; - applies `maxTasks`, `maxExports`, and `retentionDays` pruning. - `shutdown` params `{}` returns `{"ok":true}` and exits. ## 3. Errors Stable codes: `UNKNOWN_METHOD`, `BAD_REQUEST`, `CORE_UNAVAILABLE`, `PROTOCOL_MISMATCH`, `SCHEMA_MISMATCH`, `DB_ERROR`, `INTERNAL`, `STALE_LEASE`, `NO_SANDBOX_ROOT`, `BAD_ROOT`, `BAD_SUBDIR`, `ROOT_PROTECTED`, `PATH_OUTSIDE`, `TASK_NOT_FOUND`, `TASK_BUSY`, `TASK_RUNNING`, `TASK_NOT_RESUMABLE`, `EXPORT_DISABLED`, `TARGET_INSIDE_SANDBOX`, `EXPORT_LIMIT`, `EXPORT_NOT_FOUND`, `FILE_TOO_LARGE`, `FILE_NOT_FOUND`, `BINARY_FILE`, `UNDO_CONFLICT`, `LEGACY_READONLY`, `CONFIG_INVALID`. The Host HTTP envelope additionally uses: - `MAIN_SESSION_NOT_LIVE` — the requested main session has no live Agent, so a return-to-main cannot be delivered; - HTTP-layer codes `FORBIDDEN` (403), `UNAUTHORIZED` (401 bootstrap), `NOT_FOUND` (404), `METHOD_NOT_ALLOWED` (405), and `BAD_REQUEST` (413 oversized body / malformed JSON). The Client also defines its own transport codes (`TOKEN_MISSING`, `TOKEN_STALE`, `NETWORK`, `INVALID_RESPONSE`, `ABORTED`) that never cross the Host wire. ## 4. Configuration All keys are camelCase on the wire. Defaults, ranges, and behavior: | Key | Default | Rules / effect | |---|---|---| | `sandboxRoot` | `""` | Absolute dedicated directory or empty. Empty refuses every task; the plugin never falls back to the workspace. Normalized on read/write (trailing separators removed; drive root keeps one separator). Rejected with `ROOT_PROTECTED` when it is a filesystem root, the user home, `DSH_HOME`, the DB directory, the current cwd, `SystemRoot`, or `ProgramFiles`, or when it contains one of those. | | `allowWrites` | `true` | `false` installs `read-only` sandbox mode and removes `write`/`edit` from the effective allowlist. | | `allowedTools` | `["pwsh","bash","read","write","edit","glob","grep"]` | Non-empty string array; the **upper bound** enforced by the tool guard. Not intersected with a preset. | | `timeoutMs` | `600000` | Per-turn timeout; clamped to `30_000..7_200_000`. Snapshot per task. | | `readContext` | `true` | Read up to 12 recent main-session messages (user 700 / assistant 900 chars, total ≤ 12 000) into the pod system prompt. | | `maxConcurrent` | `3` | `1..8`; counts `starting` + `running`. | | `queueEnabled` | `true` | When the limit is reached, queue tasks (FIFO) instead of returning `TASK_BUSY`. With it disabled, a new task gets `TASK_BUSY` only when no queue exists; an existing queue still accepts new tasks. | | `enableExport` | `true` | `false` makes `export.create` fail with `EXPORT_DISABLED`. | | `allowRememberChoice` | `false` | Enables the "remember this choice" checkbox; it only pre-fills the target path. Every export still needs a confirmation. | | `presetId` | `""` | Agent preset mounted in the pod session. Empty means no preset is mounted; choose a preset that supplies the tools you allow. | | `maxTasks` | `200` | `1..5000`; terminal task records above the cap are pruned oldest-first at startup. Sandbox files are not deleted by this prune. | | `maxExports` | `200` | `1..5000`; export records above the cap are pruned with their undo backups at startup and after each successful export. | | `retentionDays` | `0` | `0..3650`; `0` disables automatic cleanup. Positive values delete terminal task **records** older than the window at startup; sandbox files remain. | | `previewBytes` | `307200` | `16 KiB..1 MiB`; per-file byte ceiling enforced by the sidecar's `files.read`. | | `previewChars` | `20000` | `200..20000`; character ceiling used by the preview UI. | | `exportVerify` | `true` | Always record a BLAKE3 hash on export; this flag controls whether undo verifies it. | | `sandboxProbeMode` | `"strict"` | `strict` or `warn`. An **allowed** outside write always fails the task in both modes. `ambiguous`/`failed` fail in `strict` and continue with a warning in `warn`. `skipped-no-parent` (sandbox root is a drive root) passes with a warning. | | `autoResumeOnOpen` | `true` | When a task is opened and its session is not live, attempt `task.reattach` + `agents.resume` before the official conversation opens. | | `logLevel` | `"info"` | `trace`, `debug`, `info`, `warn`, `error`, or `off`. The Config UI exposes `error/warn/info/debug`; the API accepts all six. | Methods: - `config.get` → `{"config": Config}` - `config.validate {patch: Partial}` → `{"config": Config, "messages": string[]}` (does not persist) - `config.set {patch: Partial}` → `{"config": Config, "messages": string[]}` Invalid values are ignored or clamped with a message rather than failing the whole patch; a non-object patch is `BAD_REQUEST`. A rejected `sandboxRoot` keeps the old value. `allowedTools` may not become empty. ## 5. Task state machine Statuses: `queued | starting | running | completed | failed | timeout | cancelled | interrupted` Terminal statuses: `completed | failed | timeout | cancelled | interrupted`. `TaskSummary` (wire): ```json { "id": "pod-...", "sessionId": "pod-session-...", "mainSessionId": "session-...", "label": "...", "description": "...", "status": "running", "subdir": "", "taskDir": "E:\\pod-work\\pod-...", "sandboxRoot": "E:\\pod-work", "createdAt": 0, "queuedAt": 0, "startedAt": 0, "endedAt": 0, "turns": 1, "timeoutMs": 600000, "allow": ["read"], "availableTools": [], "presetId": "", "model": {"provider": "", "model": "", "reasoningEffort": null}, "sandboxMode": "workspace-write", "sandboxProbe": "denied", "fsDenied": true, "lastText": "", "error": "", "legacy": false, "turnDeadline": 0, "queuedPosition": 0 } ``` `live` and `canResume` are derived by the Host for the UI and are not stored by the sidecar. A task's `sessionId` is the DSH session ID; `id` is the task record ID. They are distinct values. | Method | Params | Result / effect | |---|---|---| | `task.list` | `{}` | `{"tasks":[TaskSummary]}` | | `task.get` | `{taskId}` | `{"task":TaskSummary}` | | `task.reserve` | `{taskId, sessionId, mainSessionId, label, description, subdir, timeoutMs, allow, presetId, model, leaseId}` | Validates the sandbox root and subdir, creates `//`, inserts the task, and either starts it (`startNow:true`, with `policy`) or queues it (`startNow:false`, `queuedPosition`). Rejects an existing `taskId` with `TASK_BUSY`. | | `task.reattach` | `{taskId, leaseId}` | Re-acquires a terminal/interrupted task; sets `starting`, preserves timestamps/turns, does not restart the turn. `TASK_BUSY` while another lease is live (heartbeat younger than 30 s). | | `task.markStarting` | `{taskId, leaseId}` | Idempotent; used after `queue.dispatch` when the task is already `starting`. | | `task.markRunning` | `{taskId, leaseId}` | Moves to `running`. | | `task.turnStarted` | `{taskId, leaseId, turn}` | Moves to `running`, increments turns, sets the per-turn deadline. Accepted from a terminal task with the same lease (official-composer follow-up). | | `task.turnEnded` | `{taskId, leaseId, turn, reason, timedOut?, lastText?}` | Settles the turn. `timedOut:true` (or an already-emitted deadline) maps to `timeout`; otherwise `reason` maps as below. The lease is retained for a later follow-up. | | `task.setLastText` | `{taskId, leaseId, text}` | Mirror the latest assistant text (≤ 4000 chars). | | `task.setSandboxCheck` | `{taskId, leaseId, check}` | Store the sandbox self-test verdict. | | `task.setAvailableTools` | `{taskId, leaseId, tools}` | Store the observed tool surface (≤ 256 names). | | `task.setError` | `{taskId, leaseId, code, message}` | Store the error; a non-terminal task becomes `failed` and its slot is released. | | `task.cancel` | `{taskId}` | Request cancellation; the Host still cancels the live Agent. | | `task.heartbeat` | `{taskId, leaseId}` | Refresh the lease heartbeat. | | `task.cleanupFiles` | `{taskId}` | Delete the task directory; refuses the sandbox root or an out-of-root directory. The Host cancels active/queued tasks before calling this. | | `task.delete` | `{taskId}` | Refuses an active task (`TASK_RUNNING`); deletes the task record, audit rows, and legacy log, then dispatches the queue. Export records are kept. | | `task.bySession` | `{sessionId}` | `{"task": TaskSummary-or-null}` | | `task.legacyLog` | `{taskId}` | `{"entries":[{seq,type,kind,time,text,think}]}` for migrated tasks. | | `task.bulkCleanup` | `{statuses?, olderThanMs?, deleteFiles?}` | Delete tasks matching the requested statuses (default `completed`), optional age filter, up to the first 500 tasks; optional file deletion. The Host UI only passes terminal statuses, but the sidecar does not enforce terminal-only for direct callers. | | `sandbox.prepare` | `{taskId}` | Return the `SandboxPolicy` for a queued/dispatched or reattached task. | | `files.list` | `{taskId}` | `{"root","entries":[{relPath,type,size,mtime}],"truncated"}`; depth ≤ 8, ≤ 2000 entries, symlinks not followed. | | `files.stat` | `{taskId, relPath}` | File metadata and `binary` hint. | | `files.read` | `{taskId, relPath, maxChars?}` | Text preview; enforces `previewBytes` and the 4 MiB hard cap; UTF-8/UTF-16 with BOM; binary content returns `BINARY_FILE`. | | `export.create` | `{taskId, entries, targetDir, mode?, conflict?, remember?}` | Copy/move selected entries outside the sandbox. | | `export.list` | `{}` | `{"exports":[ExportRecord]}` | | `export.undo` | `{exportId}` | Undo an export (hash/backup verified); returns removed/conflicted paths. | | `export.deleteRecord` | `{exportId}` | Delete the record and its undo backup directory. | | `health` | `{}` | Lightweight liveness facts (version, protocol, schema, pid). | | `diagnostics.get` | `{}` | Diagnostics snapshot (superset of the Host `/status` diagnostics). | `SandboxPolicy`: ```json { "root": "E:\\pod-work", "rootDisplay": "E:\\pod-work", "taskDir": "E:\\pod-work\\pod-...", "caseInsensitive": true, "separator": "\\", "longPathPrefix": "" } ``` `SandboxCheck`: ```json { "mode": "workspace-write", "root": "E:\\pod-work", "probe": "denied|allowed|ambiguous|skipped-no-parent|failed", "fsDenied": true, "note": "", "at": 0 } ``` Turn-end reason mapping: | reason | status | |---|---| | `completed` | `completed` | | `aborted` | `cancelled` (or `timeout` when `timedOut`/deadline was set) | | `error` / `blocked` | `failed` | | `max-tokens` / `forked` | `completed` (the turn finished; the Host decides whether to continue) | | `interrupted` | `interrupted` | Queue behavior: when a task reaches a terminal status or a queued task is cancelled, the sidecar selects the FIFO next queued task, moves it to `starting`, and emits `queue.dispatch` with its `taskId`. The Host then calls `sandbox.prepare`, `task.markStarting` (idempotent), creates/resumes the Agent, and calls `task.markRunning` and `task.turnStarted`. Deadline behavior: a background ticker checks `turnDeadline`; it emits `deadline.expired {taskId, turn}` once, then waits a 10-second grace period. If the Host has not settled the turn by then, the sidecar marks it `timeout` and releases the slot. The Host also runs its own watchdog at `timeoutMs + 30 s`. Lease behavior: a lease older than 30 seconds without a heartbeat cannot be reattached. The Host heartbeats every 15 seconds, including completed tasks whose live Agent can still accept an official-composer follow-up. On sidecar startup all leases are cleared. ## 6. Events The sidecar may emit: - `queue.dispatch {"kind":"queue.dispatch","taskId":"..."}` - `deadline.expired {"kind":"deadline.expired","taskId":"...","turn":2}` - `task.changed {"kind":"task.changed","taskId":"...","status":"...","task":TaskSummary}` - `core.warning` (diagnostic quality warnings, optional) `task.changed` is authoritative for the Host's task cache; the Host preserves its own held lease when adopting the summary. ## 7. Files - `relPath` is relative to the task directory. Absolute paths, `..`, NUL bytes, symlink traversal, and paths outside the task directory are rejected. - The listing is capped at depth 8 and 2000 entries; files over the configured `previewBytes` are rejected by `files.read` with `FILE_TOO_LARGE`. - UTF-8 and UTF-16 (LE/BE with BOM) are decoded; binary content (NUL bytes or invalid UTF-8) returns `BINARY_FILE` with `binary:true` in `error.data`. - `maxChars` is clamped to 200..20 000 by the Host UI; the sidecar accepts up to 1 000 000 as a transport safety bound. Sandbox path rules: - `sandboxRoot` must be a dedicated absolute directory. It is rejected with `ROOT_PROTECTED` when it is a filesystem root, the user home, `DSH_HOME`, the DB directory, the current cwd, `SystemRoot`, or `ProgramFiles`, or when it contains one of those. - `subdir` is relative to the sandbox root: at most 8 segments, each at most 64 characters; no `.`/`..`, control characters, `< > : " | ? *`, trailing dot/space, or Windows reserved device names. - `taskId` is at most 160 characters and cannot contain path separators, NUL, or a reserved path segment. The task directory is `//`. - `relPath` is at most 4096 characters and is rejected when absolute, containing NUL/`..`, or resolving through a symlink/junction outside the task directory. ## 8. Exports `ExportRecord`: ```json { "id": "exp-...", "taskId": "pod-...", "label": "...", "at": 0, "targetDir": "E:\\work", "mode": "copy|move", "conflict": "overwrite|skip|rename", "status": "running|completed|failed|interrupted", "undoneAt": 0, "legacy": false, "items": [ { "status": "OK|SKIP|FAIL|UNDO|UNDO_CONFLICT", "src": "...", "dst": "...", "bytes": 0, "hash": "", "existed": false, "backupPath": "", "message": "" } ] } ``` - `targetDir` must be absolute and outside the sandbox root/task directory. - `mode: copy` leaves the source in place; `mode: move` removes the source after a successful copy. The UI only offers **undo for copy**; the core's undo can also move a file back when the original source path is still free, but that is not exposed as a normal UI action. - Conflict handling is applied per **flattened file** (a selected directory expands to its files): - `overwrite`: copy the existing target to `/.isolation-pod-undo//...`, then overwrite; - `skip`: leave the existing target and record `SKIP`; - `rename`: choose a free `-` destination without overwriting. - Caps: ≤ 5000 files, ≤ 2 GiB total, ≤ 1 GiB per file, directory walk depth ≤ 32. - Every successful export records a BLAKE3 hash of the exported file. When `exportVerify` is enabled, undo only removes the file if the current hash matches; if a backup exists it restores the original. Hash mismatch/removed targets become `UNDO_CONFLICT`. With verification disabled, or for legacy records without a hash, undo skips that check and may delete the target unverified. - Every export is a separate user-confirmed operation. `allowRememberChoice` only pre-fills a path; it never authorizes a later export. ## 9. Host HTTP surface The Host registers one prefix on the DSH web server: | Route | Auth | Purpose | |---|---|---| | `GET /isolation-pod/status` | none (local diagnostics) | Version, core status, DB/binary paths, sandbox summary, compat flags, recent diagnostics. Never contains the full bearer token; only an 8-character `tokenPrefix`. | | `GET /isolation-pod/bootstrap` | DSH browser auth (`connection.requestRejection`) | Returns `{"ok":true,"token":"..."}` to an authenticated page; 401 otherwise. | | `POST /isolation-pod/api` | `x-ipp-token` header | JSON `{"method":"...","args":{}}`; body ≤ 256 KiB. | The token is a random UUID generated on each Host activation. It is delivered as the structured index-injection row `__DSH_IPP__` and never written to disk. The Client recovery ladder is `__DSH_IPP__` → `/bootstrap` → same-origin HTML scrape → one guarded reload (5-second `sessionStorage` guard). HTTP failures use `{ok:false, code, message}`; the route layer returns 403 for a missing/wrong token, 404 for unknown paths, 405 for wrong methods, and 413 for an oversized body. ### 9.1 Host methods | Method | Args | Result | |---|---|---| | `getState` | `{mainSessionId?}` | Plugin/core facts, config, tool/preset choices, sandbox verdict, task summaries (with `live`/`canResume`), export records, diagnostics, compat flags. | | `setConfig` | `{patch}` | Normalized config + adjustment messages. | | `startTask` | `{mainSessionId, description, subdir?, label?}` | `{taskId, sessionId, task}`; task may be `queued`. | | `openTask` | `{taskId}` | `{sessionId, task, live, resumeError}`; may auto-resume when `autoResumeOnOpen`. | | `resumeTask` | `{taskId}` | `{sessionId, task, live}`; reattaches/resumes a stored task. | | `cancelTask` | `{taskId, reason?}` | Cancels the current turn or a queued task. | | `cleanup` | `{taskId, scope?: "record"\|"files"\|"all"}` | Cancels active/queued tasks first, then deletes files and/or the record. | | `bulkCleanup` | `{statuses?, olderThanMs?, deleteFiles?}` | Bulk cleanup by status (UI passes terminal statuses; the API does not enforce terminal-only); per-task results. | | `listFiles` | `{taskId}` | Sandbox file listing. | | `readSandboxFile` | `{taskId, relPath, maxChars?}` | Text preview; `BINARY_FILE` for binary content. | | `previewReturn` | `{taskId, parts?}` | Build an editable return preview (≤ 4000 chars). | | `returnToMain` | `{mainSessionId, text}` | Inject **one** user message into a live main session. No auto-return. | | `exportEntries` | `{taskId, entries, targetDir, mode?, conflict?, remember?}` | Start one export; returns the record. | | `listExports` | `{}` | Export records. | | `undoExport` | `{exportId}` | Undo a copy export; returns removed/conflicted paths. | | `getDiagnostics` | `{}` | Diagnostics, stderr tail, core/plugin facts, compat flags. | | `restartCore` | `{}` | Release live Agents and restart the sidecar; running tasks become `interrupted`. | | `getLegacyTask` | `{taskId}` | Migrated read-only transcript entries. | `getState` sandbox verdict: the Host compares the sandbox root with the **user's main session** cwd. A pod task conversation is a normal root session whose cwd is the sandbox root; when it is the current main view, the Host resolves it back to the task's recorded `mainSessionId` and never uses the pod cwd for the "sandbox is inside the workspace" warning. Subagent sessions are ignored for this verdict as well. ## 10. Versioning - Plugin and core version: `0.2.0`. - Protocol version: `1`. - SQLite schema version: `1`. - Version mismatches are reported by the Host and block task starts without unloading the plugin; a Client/Host/ sidecar set must come from the same release or commit.