diff --git a/README.md b/README.md index 5efdb8790..9fc3b7ac0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Launch a shell or agent in a project directory, move and resize its live termina Agents start in **Auto** by default: the CLI's own auto mode (Claude Code, Codex, Grok), CanvasTTY's per-run rules for OpenCode, or, for a CLI without one, its approval bypass only inside agent isolation. Manual, Accept edits, Plan and Bypass are offered only where the CLI has them; Bypass is acknowledged once per CLI and never handed to a subagent. Around every mode sit layers the person controls in Settings → Agents: base protection (hooks that refuse elevation, `curl | sh`, disk commands and writes outside the project before the tool call runs), delegation rules (a subagent never gets more than its orchestrator and stays in its project; 2 levels and 8 live subagents by default), and an OS isolation layer (macOS `sandbox-exec`, Linux bubblewrap; none on Windows yet, where subagents run in Manual). When an isolated session ends, CanvasTTY checks the repositories it touched for git settings that would run programs outside isolation and offers to neutralize them. -An **Orchestrator** session gets the `canvastty_agents` tools: `list_providers` (installed agents, sign-in state, models, efforts, profiles), `spawn_agent` with optional `model`, `effort` and `profile`, `wait_for_agent` (up to 600 s) and `get_agent_result`, which returns a Codex or OpenCode subagent's final reply as `answer` (up to 4,096 characters, masked). See [agent orchestration and isolation](docs/agent-orchestration.md) and [protection layers](docs/installing-and-security.md#agent-protection-layers). +An **Orchestrator** session gets the `canvastty_agents` tools: `list_providers` (installed agents, sign-in state, models, efforts, profiles), `spawn_agent` with optional `model`, `effort` and `profile`, `wait_for_agent` (up to 100 s per call, capped at 50 s for OpenCode) and `get_agent_result`, which returns a Codex or OpenCode subagent's final reply as `answer` (up to 4,096 characters, masked). See [agent orchestration and isolation](docs/agent-orchestration.md) and [protection layers](docs/installing-and-security.md#agent-protection-layers). ## Windows shells and provider CLIs diff --git a/agent/orchestrator/SKILL.md b/agent/orchestrator/SKILL.md index 1b68bdb22..6b7e117b7 100644 --- a/agent/orchestrator/SKILL.md +++ b/agent/orchestrator/SKILL.md @@ -11,7 +11,7 @@ A session started from CanvasTTY's launcher with the **Orchestrator** role has t 1. `list_providers`: which agents CanvasTTY can launch here. Each entry's `id` is the exact `spawn_agent.provider` value; prefer entries that are `available` with `signIn: "ok"` (`unknown` only means CanvasTTY has not checked yet). When plugins offer launch options (for example an accounts plugin's `list_routes` tool), the answer names that tool. 2. `spawn_agent` once per part of the task: a known `provider` id, an absolute `cwd` and a self-contained prompt. An unknown provider is refused with a pointer back to `list_providers`. **If the person names a model, pass it as `model`** in the format `list_providers` gives for that provider (`model.format`; OpenCode takes `provider/model`, for example `zai-coding-plan/glm-5.3-flash`, and `model.known` lists what `opencode models` reported); without `model` the CLI uses its own default. `effort` sets the reasoning effort where the CLI has one (`efforts`). Without `profile` the subagent gets your own launch profile, or the next lower one its CLI has (never more than yours, never YOLO); `list_providers` shows each provider's `profiles`. `cwd` must be this project's folder or a folder inside it. A refusal says why (a folder outside the project, a profile above yours, a limit the person set): change the request, do not retry it. -3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 600; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. +3. `wait_for_agent` for each subagent (`timeoutSeconds` up to 100; OpenCode calls return within 50 seconds; call it again after `timeout`) instead of polling. `needs_approval` means the person must answer a prompt in that card: never answer it yourself. 4. `get_agent_result` to read what the subagent produced; check the actual files before reporting success. Do not explore the filesystem, `PATH` or config folders (for example `~/.local/bin` or `~/.config/`) looking for agents or their configuration: `list_providers` is what CanvasTTY can launch, and delegation goes through `spawn_agent`. diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 0e8b6e3f4..bed8bec66 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -10,8 +10,8 @@ A session launched from the desktop with the **Orchestrator** role gets the `can | --- | --- | | `list_providers` | The agent providers CanvasTTY can launch as subagents: `id` (the exact `spawn_agent.provider` value), `name`, `installed`/`available` (from the provider CLI registry resolved at startup or on a recheck), `signIn` (`ok`, `signed_out`, `expired` or `unknown`, from the last usage-limits read; it never starts a read and never reads credentials), `subagent` and `orchestrator` support, `model` (whether the CLI takes one, its format, and for OpenCode the models a cached `opencode models` listing reported: run in the background with a 5 s timeout, refreshed after 10 minutes, never awaited) and `efforts`, and plugin launch options (`launchOptions`, plus `launchOptionTools` such as an accounts plugin's `list_routes`). Fast: cached state only, no network calls. | | `spawn_agent` | Launches a subagent (profile: see below). `provider` must be a `list_providers` id (the schema lists them); an unknown one is refused with `INVALID_REQUEST` naming `list_providers`. Optional `model` goes to the CLI's own `--model` for that run (OpenCode `provider/model`; Codex, Claude, Qwen, Kimi alias, Grok, OMP, Pi, Cursor; Hermes, MiniMax, Devin and Antigravity take none) and optional `effort` to its reasoning effort (Codex `-c model_reasoning_effort`, Claude `--effort`, Grok `--reasoning-effort`). Both are checked per provider, kept on restart and restore, and never written to the CLI's configuration. If the person names a model, the orchestrator passes it as `model`. An OpenCode model its own `opencode models` list does not contain is refused before launch with up to five closest listed ids (OpenCode would otherwise stop with only "Unexpected server error"); when that list cannot be read, the model is passed as given. A subagent that exits anyway reports the last lines of its screen as `exitLines` in `wait_for_agent`, `observe_agent` and `get_agent_result`. | -| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 600 }` (default 55). Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | -| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. `observe_agent`: the current status and tail. | +| `wait_for_agent` | `{ sessionId, timeoutSeconds ≤ 100 }` (default 55; OpenCode caps each call at 50 seconds). A timeout returns progress while the agent continues; call again to keep waiting. Pending reviews do not extend this deadline. Returns when the subagent is `idle` (after a prompt: only once a turn that started after the latest delivered prompt has ended, so the CLI's startup idle does not count), `needs_approval`, `done`/`failed` (exited), `quiet` (reports no status and its screen stopped changing), `closed`, or on `timeout`, with `status`, `exitCode`, `waitedMs` and the masked terminal tail (masked before it is cut). Only this orchestrator's own subagents; it stops at once when the call is canceled or the orchestrator disconnects. | +| `get_agent_result`, `observe_agent` | `get_agent_result`: `answer`, the final reply of the subagent's last turn as the agent itself reported it (Codex through its Stop hook, OpenCode through CanvasTTY's plugin reading the session's last assistant message at `session.idle`; at most 4,096 characters with the end kept, masked, in memory only and cleared when the next turn starts), plus `status`, the exit state and the masked terminal tail. Other providers have no `answer`: their tail is raw screen output. An unfinished review returns `review.status: "pending"` immediately; call `get_agent_result` later for its outcome. `observe_agent`: the current status and tail. | | `send_to_agent`, `cancel_agent`, `list_agents` | Follow-up prompts, disposing a subagent, listing this session's subagents. | ### Subagent profiles diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eefc35363..7f45de7f5 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { rollupOptions: { - input: resolve("src/main/index.ts") + input: {index: resolve("src/main/index.ts"), SessionTimelineIndexWorker: resolve("src/main/services/SessionTimelineIndexWorker.ts")} } } }, diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a27..df1d2d49c 100644 --- a/native/canvastty-helper/catalog.json +++ b/native/canvastty-helper/catalog.json @@ -964,7 +964,7 @@ }, { "name": "spawn_agent", - "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", + "description": "Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: codex, claude, qwen, kimi, opencode, hermes, grok, omp, pi, cursor, minimax, devin, antigravity); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. \"auto\" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {\"canvastty-accounts\":{\"account\":\"none\"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1028,6 +1028,14 @@ "max" ] }, + "review": { + "type": "boolean" + }, + "reviewModel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "launchOptions": { "type": "object", "maxProperties": 16, @@ -1095,7 +1103,7 @@ }, { "name": "wait_for_agent", - "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 600), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.", + "description": "Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason \"idle\" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), \"needs_approval\" (its card shows a prompt only the person may answer; never answer it yourself), \"done\" or \"failed\" (its process exited), \"quiet\" (it reports no status or no turn start and its screen stopped changing, so judge from output), \"closed\" (its card was closed) or \"timeout\" after timeoutSeconds (default 55, at most 100), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. OpenCode calls return within 50 seconds to fit its client's deadline. A timeout means it is still running: use output as progress and call wait_for_agent again. Pending reviews also return within this deadline. Then read get_agent_result.", "inputSchema": { "type": "object", "properties": { @@ -1107,7 +1115,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "maximum": 600 + "maximum": 100 } }, "required": [ @@ -1118,7 +1126,7 @@ }, { "name": "get_agent_result", - "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "description": "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. An unfinished review returns status pending immediately; call get_agent_result later for its outcome.", "inputSchema": { "type": "object", "properties": { @@ -1159,6 +1167,183 @@ "required": [], "additionalProperties": false } + }, + { + "name": "retry_agent", + "description": "Replace one failed or quiet subagent launch with a fresh conversation in the same card, stopping its previous process first. Retains task ownership, history, launch profile, model and folder; sends the original prompt plus a short masked failure tail. Only one retry may run at a time, with at most two successful retries per original agent. A failed retry keeps the stopped card for inspection; it cannot restore a stopped process.", + "inputSchema": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": false + } + }, + { + "name": "list_tasks", + "description": "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "claim_task", + "description": "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "update_task", + "description": "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 240 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + }, + "progress": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "status": { + "type": "string", + "enum": [ + "open", + "claimed" + ] + }, + "ownerSessionId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "ownerName": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "dependencies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "maxItems": 64 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + } + }, + { + "name": "complete_task", + "description": "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 160 + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + } + }, + "required": [ + "taskId", + "result" + ], + "additionalProperties": false + } + }, + { + "name": "get_task_budget", + "description": "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "list_orchestration_templates", + "description": "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }, + { + "name": "apply_orchestration_template", + "description": "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + "inputSchema": { + "type": "object", + "properties": { + "templateId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "task": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "templateId", + "task" + ], + "additionalProperties": false + } } ], "providerIds": [ diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d22..1254948cf 100644 --- a/src/agent-browser/orchestration-catalog.mjs +++ b/src/agent-browser/orchestration-catalog.mjs @@ -6,6 +6,7 @@ export const MAX_ORCHESTRATION_PAYLOAD_BYTES = 128 * 1024; const string = (options = {}) => ({ type: "string", ...options }); const boolean = () => ({ type: "boolean" }); const integer = (options = {}) => ({ type: "integer", ...options }); +const array = (items, options = {}) => ({ type: "array", items, ...options }); const object = (properties, required = []) => ({ type: "object", properties, @@ -30,7 +31,7 @@ export const AGENT_PROVIDER_IDS = Object.freeze([ ]); /** wait_for_agent: the longest wait one call may ask for, and the wait without timeoutSeconds (some MCP clients * end a tool call after 60 seconds). */ -export const MAX_AGENT_WAIT_SECONDS = 600; +export const MAX_AGENT_WAIT_SECONDS = 100; export const DEFAULT_AGENT_WAIT_SECONDS = 55; /** spawn_agent.effort: every level some CLI takes (src/shared/launchModel.ts REASONING_EFFORTS; a test keeps them equal). */ export const REASONING_EFFORT_IDS = Object.freeze(["minimal", "low", "medium", "high", "xhigh", "max"]); @@ -58,7 +59,7 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "spawn_agent", - `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Refusals say why (a limit the person set, a folder outside the project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, + `Launch another provider's agent as a CanvasTTY subagent of this session and optionally deliver a first prompt. Returns the new session id. provider must be an id from list_providers (known ids: ${AGENT_PROVIDER_IDS.join(", ")}); call list_providers first to see which are installed and signed in. Give each subagent one self-contained part of the task and an absolute cwd. If the person names a model, pass it as model in the format list_providers gives for that provider (OpenCode: provider/model); omit model to keep the selected account or provider default. effort sets the reasoning effort where that CLI has one (list_providers shows its efforts). An unsupported model or effort is refused with the reason. profile: without it the subagent gets this session's launch profile, or the next lower one its CLI has; a subagent never gets more than this session (plan < normal < acceptEdits < auto) and never YOLO. "auto" lets it work without asking the person for each edit or command inside the project; CanvasTTY's outer layers (agent isolation, base protection) still stop writes outside the project, secrets and system changes. The answer says the profile it got. cwd must be this project's folder or a folder inside it. review: true asks CanvasTTY to run a read-only review after this agent finishes; reviewModel optionally selects a different known model for the reviewer. launchOptions passes plugin launch options exactly as a plugin tool gives them (for example the account a plugin picked), for plugins that allow an orchestrator to choose them. Without a model account in launchOptions the subagent runs on this session's own model account when this session has one; {"canvastty-accounts":{"account":"none"}} explicitly runs it on the CLI's own sign-in and default model. The answer's servedBy says which provider, account and model serve it. Refusals say why (a limit the person set, a folder outside this session's project, a profile above this session's): change the request instead of retrying it. Then call wait_for_agent and get_agent_result.`, { provider, cwd: string({ minLength: 1, maxLength: 4_096 }), @@ -67,6 +68,8 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ profile: string({ enum: ["auto", "normal", "acceptEdits", "plan"] }), model: string({ minLength: 1, maxLength: 200 }), effort: string({ enum: [...REASONING_EFFORT_IDS] }), + review: boolean(), + reviewModel: string({ minLength: 1, maxLength: 200 }), launchOptions }, ["provider", "cwd"] @@ -85,13 +88,13 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ ), tool( "wait_for_agent", - `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. After a timeout, call it again. Then read get_agent_result.`, + `Wait until one of this session's subagents stops working, instead of polling observe_agent or get_agent_result; nothing is sent to it while it waits. Returns reason "idle" (the turn that answers your latest prompt ended and it waits for input; an idle before that turn started does not count), "needs_approval" (its card shows a prompt only the person may answer; never answer it yourself), "done" or "failed" (its process exited), "quiet" (it reports no status or no turn start and its screen stopped changing, so judge from output), "closed" (its card was closed) or "timeout" after timeoutSeconds (default ${DEFAULT_AGENT_WAIT_SECONDS}, at most ${MAX_AGENT_WAIT_SECONDS}), with status, exitCode, waitedMs, the masked terminal tail as output, and answer (the final reply of the turn that ended, for Codex and OpenCode subagents); when the subagent's process exited (for example at once, on a model its CLI does not know), exitLines holds the last lines of its screen as plain text, which say why. OpenCode calls return within 50 seconds to fit its client's deadline. A timeout means it is still running: use output as progress and call wait_for_agent again. Pending reviews also return within this deadline. Then read get_agent_result.`, { sessionId, timeoutSeconds: integer({ minimum: 1, maximum: MAX_AGENT_WAIT_SECONDS }) }, ["sessionId"] ), tool( "get_agent_result", - "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output.", + "Get one of this session's subagents' result: answer (its last turn's final reply as the agent reported it, for Codex and OpenCode subagents; truncated:true when only the end was kept), status (idle once its turn ended), the exit state (running | done | failed; an interactive CLI stays running after a task) and the masked terminal tail as output. Prefer answer; the terminal tail is raw screen output. An unfinished review returns status pending immediately; call get_agent_result later for its outcome.", { sessionId }, ["sessionId"] ), @@ -103,6 +106,63 @@ export const ORCHESTRATION_TOOL_DEFINITIONS = Object.freeze([ tool( "list_agents", "List this session's subagents with provider, status, and title." + ), + tool( + "retry_agent", + "Replace one failed or quiet subagent launch with a fresh conversation in the same card, stopping its previous process first. Retains task ownership, history, launch profile, model and folder; sends the original prompt plus a short masked failure tail. Only one retry may run at a time, with at most two successful retries per original agent. A failed retry keeps the stopped card for inspection; it cannot restore a stopped process.", + { sessionId, reason: string({ maxLength: 500 }) }, + ["sessionId"] + ), + tool( + "list_tasks", + "List the shared task board for this orchestration. Subagents see only tasks belonging to their own root orchestrator.", + {}, + [] + ), + tool( + "claim_task", + "Atomically claim one open task whose dependencies are all complete. If another agent owns it, the current owner is returned.", + { taskId: string({ minLength: 1, maxLength: 160 }) }, + ["taskId"] + ), + tool( + "update_task", + "Update the task you own. Only the root orchestrator can reassign tasks or change dependencies; complete work with complete_task.", + { + taskId: string({ minLength: 1, maxLength: 160 }), + title: string({ minLength: 1, maxLength: 240 }), + description: string({ minLength: 1, maxLength: 8_000 }), + progress: string({ minLength: 1, maxLength: 4_000 }), + status: string({ enum: ["open", "claimed"] }), + ownerSessionId: string({ minLength: 1, maxLength: 160 }), + ownerName: string({ minLength: 1, maxLength: 160 }), + dependencies: array(string({ minLength: 1, maxLength: 80 }), { maxItems: 64 }) + }, + ["taskId"] + ), + tool( + "complete_task", + "Mark a task complete and record its result. Only its owner or the root orchestrator can complete it, and dependencies must be complete.", + { taskId: string({ minLength: 1, maxLength: 160 }), result: string({ minLength: 1, maxLength: 8_000 }) }, + ["taskId", "result"] + ), + tool( + "get_task_budget", + "Read the budget for this orchestration tree. Unknown provider token or cost usage is returned as no data; only a person can change or remove limits.", + {}, + [] + ), + tool( + "list_orchestration_templates", + "List built-in and project orchestration flows from .canvastty/flows. Invalid files are returned as line-numbered errors.", + {}, + [] + ), + tool( + "apply_orchestration_template", + "Get the roles, expected number of subagents and final-step instructions for a flow, ready to apply to a task.", + { templateId: string({ minLength: 1, maxLength: 64 }), task: prompt }, + ["templateId", "task"] ) ]); @@ -173,6 +233,21 @@ export function validateOrchestrationArguments(toolName, args) { else if (property.minimum !== undefined && candidate < property.minimum) errors.push(`${key} is below the minimum.`); else if (property.maximum !== undefined && candidate > property.maximum) errors.push(`${key} is above the maximum.`); else value[key] = candidate; + } else if (property.type === "array") { + if (!Array.isArray(candidate) || candidate.length > (property.maxItems ?? Number.MAX_SAFE_INTEGER)) { + errors.push(`${key} must be an array of at most ${property.maxItems ?? "the allowed number of"} items.`); + } else { + const items = []; + for (let index = 0; index < candidate.length; index += 1) { + const item = candidate[index]; + if (property.items?.type === "string") { + if (typeof item !== "string") errors.push(`${key}[${index}] must be a string.`); + else if (item.length < (property.items.minLength ?? 0) || item.length > (property.items.maxLength ?? Number.MAX_SAFE_INTEGER)) errors.push(`${key}[${index}] has an invalid length.`); + else items.push(item); + } else errors.push(`${key} has an unsupported item type.`); + } + value[key] = items; + } } } for (const key of Object.keys(args)) { diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index bd8151b86..355b20ca1 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -3,10 +3,12 @@ import { CAPTURE_RESULT_ENV } from "./runtime-protocol.mjs"; import { finalAnswer } from "./opencode-final-answer.mjs"; import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { HOOK_TIMEOUT_MS, preparePluginHook } from "./plugin-hook-dispatch.mjs"; let rootSessionId = null; let rootWorking = false; +let rootTurnId = null; const lifecycleEnabled = process.env.CANVASTTY_LIFECYCLE_HOOKS_ENABLED !== "0"; const pluginHookRegistry = process.env.CANVASTTY_PLUGIN_HOOK_REGISTRY ?? ""; const pluginHookRunnerCommand = process.env.CANVASTTY_PLUGIN_HOOK_RUNNER_COMMAND ?? ""; @@ -45,8 +47,9 @@ async function lifecycleEvent(event, decisions, client) { if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); rootWorking = false; + rootTurnId = null; if (!rootSessionId) return; - if (lifecycleEnabled) { + if (lifecycleEnabled || captureResult) { await reportLifecycle({ state: "idle", event: event.type, threadId: rootSessionId }); } runPluginHooks("session-start", event.type, event); @@ -63,44 +66,54 @@ async function lifecycleEvent(event, decisions, client) { ? statusValue.type : null; if (status === "busy" || status === "retry") { - if (lifecycleEnabled) { - await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootSessionId }); + const startsTurn = status === "busy" && !rootWorking; + if (startsTurn || !rootTurnId) rootTurnId = randomUUID(); + rootWorking = true; + if (lifecycleEnabled || captureResult) { + await reportLifecycle({ state: "working", event: `session.status:${status}`, turnId: rootTurnId }); } - if (status === "busy" && !rootWorking) { + if (startsTurn) { runPluginHooks("prompt-submit", `session.status:${status}`, event); } - rootWorking = true; } else if (status === "idle") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootSessionId }); + // A result-capturing turn is not complete until its bounded session.idle SDK read settles. Publishing idle + // here lets an orchestrator review before the final answer has reached the host. + if (lifecycleEnabled && !captureResult) await reportLifecycle({ state: "idle", event: "session.status:idle", turnId: rootTurnId }); } return; } if (event.type === "session.idle") { rootWorking = false; - const result = captureResult ? await finalAnswer(client, rootSessionId) : undefined; - if (lifecycleEnabled || result) { - await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId, ...(result ? { result } : {}) }); + const endingSessionId = rootSessionId, endingTurnId = rootTurnId; + const result = captureResult && endingTurnId ? await finalAnswer(client, endingSessionId) : undefined; + // SDK reads are asynchronous: the next turn may already have started while the old answer was being read. + if (endingSessionId !== rootSessionId || endingTurnId !== rootTurnId || rootWorking) return; + if (lifecycleEnabled || captureResult) { + await reportLifecycle({ state: "idle", event: event.type, turnId: rootTurnId, ...(result ? { result } : {}) }); } runPluginHooks("stop", event.type, event); } else if (event.type === "permission.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-request", event.type, event); } else if (event.type === "permission.replied") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); runPluginHooks("permission-result", event.type, event); } else if (event.type === "question.asked") { - if (lifecycleEnabled) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "needs_approval", event: event.type, turnId: rootTurnId }); } else if (event.type === "question.replied" || event.type === "question.rejected") { - if (lifecycleEnabled) await reportLifecycle({ state: "working", event: event.type, turnId: rootSessionId }); + rootWorking = true; + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "working", event: event.type, turnId: rootTurnId }); } else if (event.type === "session.error") { rootWorking = false; - if (lifecycleEnabled) await reportLifecycle({ state: "idle", event: event.type, turnId: rootSessionId }); + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "idle", event: event.type, turnId: rootTurnId }); runPluginHooks("stop", event.type, event); } else if (event.type === "session.deleted") { runPluginHooks("session-end", event.type, event); rootWorking = false; rootSessionId = null; + rootTurnId = null; } else if (event.type === "tool.execute.after") { runPluginHooks("after-tool", event.type, event); } diff --git a/src/main/editContextMenu.ts b/src/main/editContextMenu.ts new file mode 100644 index 000000000..ad553d444 --- /dev/null +++ b/src/main/editContextMenu.ts @@ -0,0 +1,36 @@ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from "electron"; +import type { LocaleId } from "../shared/contracts.ts"; + +type EditParams = Pick; + +/** + * The right-click menu for text: Cut/Copy/Paste/Select All in an editable field (an app field or a field in a plugin + * page), Copy for selected page text, nothing elsewhere. A page that handles its own right click (the canvas, a + * terminal) cancels the DOM event, and Electron then never asks for this menu. + */ +export function editContextMenuTemplate(params: EditParams, locale: LocaleId): MenuItemConstructorOptions[] { + const ru = locale === "ru"; + const label = { cut: ru ? "Вырезать" : "Cut", copy: ru ? "Скопировать" : "Copy", paste: ru ? "Вставить" : "Paste", selectAll: ru ? "Выбрать все" : "Select All" }; + if (params.isEditable) { + return [ + { role: "cut", label: label.cut, enabled: params.editFlags.canCut }, + { role: "copy", label: label.copy, enabled: params.editFlags.canCopy }, + { role: "paste", label: label.paste, enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll", label: label.selectAll, enabled: params.editFlags.canSelectAll } + ]; + } + if (params.selectionText.length > 0) return [{ role: "copy", label: label.copy, enabled: params.editFlags.canCopy }]; + return []; +} + +export function attachEditContextMenu( + contents: WebContents, + locale: () => LocaleId, + popup: (template: MenuItemConstructorOptions[], contents: WebContents) => void +): void { + contents.on("context-menu", (_event, params) => { + const template = editContextMenuTemplate(params, locale()); + if (template.length) popup(template, contents); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index e2fb63e29..eec9bbc12 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,14 @@ +import { UsagePrices } from "./services/UsagePrices"; +import { ProviderUsageSource } from "./services/ProviderUsageSource"; +import { SessionTimelineService } from "./services/SessionTimelineService"; +import { configuredModel } from "./services/configuredModel"; +import { GitCheckpoints } from "./services/GitCheckpoints"; +import { OrchestrationBudgetService } from "./services/OrchestrationBudgetService"; +import { OrchestrationTaskBoard } from "./services/OrchestrationTaskBoard"; +import { OrchestrationTemplateService } from "./services/OrchestrationTemplateService"; +import { resolveAgentHistoryPaths } from "./services/agent-history/historyPaths"; +import { refreshOrchestrationUsageBatch, type OrchestrationUsageScope } from "./services/OrchestrationUsageSync"; +import { registerBacklogIpc } from "./ipc/registerBacklogIpc"; import "./stdio"; import appIcon from "../../build/icon.png?asset"; import appManifest from "../../package.json"; @@ -92,6 +103,7 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; import { markMainBoot, mainBootMarks } from "./bootMarks"; +import { attachEditContextMenu } from "./editContextMenu"; import { macApplicationMenuTemplate } from "./macApplicationMenu"; if (process.env.CANVASTTY_USER_DATA_DIR) { @@ -164,6 +176,9 @@ async function showCompanionBrowser():Promise<{title:string;url:string}> { const state=browserService.getState(), tab=state.tabs.find(tab=>tab.id===state.activeTabId); return {title:tab?.title||"Browser",url:tab?.url||""}; } +let disposeBudgetObservers:(()=>void)|null=null; +let flushBudgets:(()=>Promise)|null=null; +let sessionTimeline:SessionTimelineService|null=null; let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; @@ -194,6 +209,8 @@ let appSurfaceReady = false; let pendingMenuUpdateCheck = false; let checkUpdatesFromMenu: (() => void) | null = null; let installMacMenu: (() => void) | null = null; +/** The locale of the text right-click menu; startApplication points it at the settings once they are loaded. */ +let editMenuLocale: () => LocaleId = () => (app.getLocale().toLowerCase().startsWith("ru") ? "ru" : "en"); let updateTimer: ReturnType | null = null; let updateInterval: ReturnType | null = null; let startupRunning = false; @@ -256,6 +273,8 @@ function createWindow(): BrowserWindow { preventMouseBindings: false, captureMacEditShortcuts: process.platform === "darwin" }); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); // Crash recovery: a dead renderer must never leave the user staring at a // blank window. The application surface is reloaded in place — the same entry // startup loads — so services, sessions and their scrollback stay untouched @@ -327,6 +346,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { recoverKimiConfigurationOnStartup(kimiHomeDirectory); const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); + editMenuLocale = () => settings.get().locale; const terminalBorderSkins = new SkinRegistry(userDataPath); const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); pluginManager = new PluginManager(userDataPath); @@ -352,6 +372,34 @@ async function initializeServices(ipc: IpcRegistrar): Promise { markMainBoot("criticalServicesReady"); // Secrets this app knows are masked in every text one agent reads from another (EP-8). const redaction = new SecretRedactionRegistry(); + const timeline = new SessionTimelineService(userDataPath,text => redaction.redact(text)); + await timeline.load(); + sessionTimeline=timeline; + const usagePrices=new UsagePrices(join(userDataPath,"usage-prices.json")); + await usagePrices.load(); + const usageSources=new Map(); + const usageSourceFor=(home:string):ProviderUsageSource=>{ + let source=usageSources.get(home); + if(!source){source=new ProviderUsageSource(home);usageSources.set(home,source);} + return source; + }; + const taskBoard=new OrchestrationTaskBoard(join(userDataPath,"task-boards")); + const templates=new OrchestrationTemplateService(join(userDataPath,"flow-approvals.json")); + let budgetInputGate:(id:string)=>void=()=>undefined; + const applyBudgetEnforcement=(row:ReturnType):void=>{ + if(!terminalManager)return; + const result=terminalManager.setBudgetPaused(row.rootSessionId,row.paused); + const failure=result.failed ? redaction.redact(result.failed) : undefined; + budgets.setEnforcementFailure(row.rootSessionId,failure); + if(failure){row.paused=true;row.reason=`${row.reason ?? "Task budget is paused."} ${failure}`;} + }; + const budgets=new OrchestrationBudgetService(join(userDataPath,"task-budgets.json"),{ + onPause:applyBudgetEnforcement, + onChange:applyBudgetEnforcement + }); + await budgets.load(); + flushBudgets=()=>budgets.flush(); + const checkpoints=new GitCheckpoints(text=>redaction.redact(text)); diagnostics.configureRedaction(text => redaction.redact(text)); diagnosticContext = () => ({ servicesReady, @@ -513,15 +561,23 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, onSignal: (terminalSessionId, signal) => { - terminalManager?.applyProviderSignal(terminalSessionId, { + const usageRow=terminalManager?.getMetadata(terminalSessionId); + if(usageRow?.provider==="codex" && signal.threadId && (!terminalManager?.pluginContext(terminalSessionId)?.environment || terminalManager.pluginContext(terminalSessionId)?.environment?.kind==="worktree")) { + const account=terminalManager!.usageAccount(terminalSessionId); + const source=usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex); + void source.codexUsage(signal.threadId).then(usage=>usage===null || !terminalManager?.getMetadata(terminalSessionId) ? undefined : timeline.recordCumulativeUsage(terminalSessionId,usage,"codex-cli conversation counter",signal.threadId,{provider:"codex",accountId:account.id,taskId:agentControlService.taskRoot(terminalSessionId).id,...(usage.model ?? usageRow.model ? {model:usage.model ?? usageRow.model} : {})},{resumed:terminalManager!.resumedConversation(terminalSessionId,signal.threadId!)})).catch(console.warn); + } + + const accepted = terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, event: signal.event, ...(signal.turnId ? { requestId: signal.turnId } : {}), ...(signal.threadId ? { threadId: signal.threadId } : {}) }); - // A subagent's final answer (Codex Stop hook, OpenCode plugin) for get_agent_result and wait_for_agent. - if (signal.result) terminalManager?.recordAnswer(terminalSessionId, signal.result); + if (!accepted) return; + // The answer belongs to the accepted provider turn and its host-submitted input generation. + if (signal.result) terminalManager?.recordAnswer(terminalSessionId, signal.result, { turnId: signal.turnId }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { evenG2?.answer( @@ -533,7 +589,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + onPermissionRequest: (terminalSessionId, request, signal) => { + try { budgetInputGate(terminalSessionId); } + catch(error) { return {behavior:"deny",message:redaction.redact(error instanceof Error ? error.message : "Task budget is paused.")}; } + return decisionHooks.decide(terminalSessionId, request, signal); + }, // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. httpHooks: true }); @@ -573,6 +633,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } // Output batches of every session flushed in one task leave as one IPC message. + let forgetOrchestrationSession=(_id:string):void=>undefined; + let scheduleUsageRefresh=():void=>undefined; + const usageMembership=new Map(); const rendererOutbox = new TerminalRendererOutbox((channel, payload) => { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) mainWindow.webContents.send(channel, payload); }); @@ -613,11 +676,14 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }).show(); } } else if (channel === IPC.terminalRemoved && "id" in payload) { + usageMembership.delete(payload.id);scheduleUsageRefresh(); + forgetOrchestrationSession(payload.id); diagnostics.record("info", "terminal", "session.closed", { id: payload.id }); notifiedAttentionStatus.delete(payload.id); } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); terminalManager.configureRedaction(redaction); + timeline.configureSessionContext(id=>{const row=terminalManager?.getMetadata(id);return row ? {taskId:terminalManager!.taskScopeFor(id).id,title:row.title || row.provider} : undefined;}); terminalManager.setKeyboardShortcuts(settings.get().shortcuts); // The operating-system isolation layer (Settings → Agents → Agent isolation) and YOLO only where the person // acknowledged it: both decided here, in the main process, for every launch whoever asks for it. @@ -659,7 +725,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // list_providers: the CLI registry resolved at startup, the last usage read (never started from here) and the // launch options trusted plugins declared. - const providerModels = new ProviderModelCatalog(providerClis); + const providerModels = new ProviderModelCatalog(providerClis,{codexHome:resolveAgentHistoryPaths().codex}); + await providerModels.refresh("codex"); // OpenCode with a model it does not list fails with only "Unexpected server error": refuse it up front. terminalManager.configureModelCheck((provider, model) => provider === "terminal" ? null : providerModels.unknownModelCached(provider, model)); const providerDirectorySources: ProviderDirectorySources = { @@ -678,8 +745,53 @@ async function initializeServices(ipc: IpcRegistrar): Promise { const managedTerminals = terminalManager; const agentControlService = new AgentControlService(managedTerminals, { limits: () => ({ maxDepth: settings.get().orchestrationMaxDepth, maxSubagents: settings.get().orchestrationMaxSubagents }), - containment: () => managedTerminals.containment() + containment: () => managedTerminals.containment(), + budget:budgets, + workerModel:async worker=>{ + const account=managedTerminals.usageAccount(worker.id); + const actual=worker.provider==="codex" && worker.threadId ? await usageSourceFor(account.home ?? resolveAgentHistoryPaths().codex).codexUsage(worker.threadId) : null; + return actual?.model ?? configuredModel(worker.provider as AgentProviderId,{codex:resolveAgentHistoryPaths().codex,claude:join(app.getPath("home"),".claude"),opencode:join(process.env.XDG_CONFIG_HOME ?? join(app.getPath("home"),".config"),"opencode")}); + }, + reviewCost:id=>timeline.usage([id],usagePrices.get()).cost, + reviewModel:(provider,model)=>model ? providerDirectorySources.models?.(provider)?.models.find(candidate=>candidate!==model) ?? null : null, + onReview:id=>managedTerminals.setTaskMetadata(id,{reviewRequested:true}) }); + budgetInputGate=id=>agentControlService.assertInputAllowed(id); + managedTerminals.configureInputGate(budgetInputGate); + let usageRefreshPending=false; + const refreshUsage=()=>{ + const rows=managedTerminals.listMetadata(),scopes=agentControlService.taskRoots(rows); + const prices=usagePrices.get(); + const roots=new Map(); + const members=new Map(),parents=new Map(); + for(const row of rows){ + if(row.provider==="terminal")continue; + const root=scopes.get(row.id);if(!root)continue; + const ids=members.get(root.id) ?? [];ids.push(row.id);members.set(root.id,ids); + if(!row.parentSessionId)parents.set(root.id,row.id); + if(row.exitCode===null)roots.set(root.id,root); + } + const usageScopes:OrchestrationUsageScope[]=[]; + for(const root of roots.values()) { + usageScopes.push({rootSessionId:root.id,rootStartedAt:root.startedAt,memberSessionIds:members.get(root.id) ?? [], + budgetEnabled:budgets.hasLimits(root.id)}); + } + refreshOrchestrationUsageBatch(budgets,timeline,usageScopes,prices,(scope,snapshot)=>{ + const parent=parents.get(scope.rootSessionId); + if(parent)managedTerminals.setTaskBudget(parent,snapshot ? {...snapshot.remaining,paused:snapshot.paused,warning:snapshot.warning, + ...(snapshot.paused && !managedTerminals.processSuspensionSupported() ? {processesKeepRunning:true} : {})} : undefined); + },scope=>{budgets.snapshot(scope.rootSessionId,scope.rootStartedAt);}); + }; + scheduleUsageRefresh=()=>{ + if(usageRefreshPending)return; + usageRefreshPending=true; + queueMicrotask(()=>{usageRefreshPending=false;refreshUsage();}); + }; + const offUsage=timeline.subscribeUsage(scheduleUsageRefresh),offPrices=usagePrices.subscribe(scheduleUsageRefresh),offBudgets=budgets.subscribe(scheduleUsageRefresh); + disposeBudgetObservers=()=>{offUsage();offPrices();offBudgets();budgets.dispose();scheduleUsageRefresh=()=>undefined;}; + scheduleUsageRefresh(); + const orchestrationHandler=new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources,{budget:budgets,taskBoard,templates}); + forgetOrchestrationSession=id=>agentControlService.forgetSession(id); orchestrationGateway = new OrchestrationGateway({ runtimeDirectory: join(userDataPath, "orchestration", "runtime"), windowsHostPath: process.platform === "win32" @@ -687,11 +799,11 @@ async function initializeServices(ipc: IpcRegistrar): Promise { ? join(process.resourcesPath, "agent-browser", WINDOWS_PIPE_HOST_FILENAME) : join(app.getAppPath(), "build", "windows-agent-pipe-host", WINDOWS_PIPE_HOST_FILENAME) : undefined, - handler: new ScopedOrchestrationHandler(agentControlService, pluginTools, providerDirectorySources) + handler: orchestrationHandler }); await orchestrationGateway.start(); terminalManager.configureOrchestration(new OrchestrationBridge(orchestrationGateway)); - terminalManager.configureAgentTools((role, provider) => pluginTools.names(role, provider)); + terminalManager.configureAgentTools((role, provider) => [...(provider!=="terminal" ? ["list_tasks","claim_task","update_task","complete_task","get_task_budget"] : []),...pluginTools.names(role, provider)]); const launchPipeline = new LaunchPipeline({ contributors: () => pluginManager!.launchContributors(), @@ -711,6 +823,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // launch options or an environment ask their plugin's service, so start services first. pluginServices.hostReady(); await pluginServicesStarted.catch(() => undefined); + for(const budget of budgets.snapshots())if(budget.paused)applyBudgetEnforcement(budget); await terminalManager.restorePersistedSessions(); // The agent-control endpoint follows Settings → Agents → "Agent orchestration // endpoint"; the start flag / env var force it on for one launch (CI smoke) @@ -785,6 +898,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }); await materialService.load(); registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow }); + registerBacklogIpc(ipc,{board:taskBoard,budgets,flows:templates,taskRoot:id=>agentControlService.taskRoot(id),terminals:managedTerminals,getMainWindow:()=>mainWindow}); registerIpc(ipc, { settings, recheckProviderClis: async () => { @@ -836,6 +950,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { if (active) browserService?.cancelCanvasNavigationGesture(); canvasNavigationInput?.setShortcutCaptureActive(active); }, + setCanvasNavigationTerminalEditFocus: (contents, active) => { + canvasNavigationInput?.setTerminalEditFocus(contents, active); + }, setCanvasNavigationPointerBinding: (input) => { canvasNavigationInput?.updatePointerBinding(input); }, @@ -1214,6 +1331,9 @@ app.on("child-process-gone", (_event, details) => { void IPC.terminalData; async function shutdownServices(): Promise { + disposeBudgetObservers?.();disposeBudgetObservers=null; + await Promise.allSettled([flushBudgets?.(), sessionTimeline?.flush()]); + diagnostics.record("info", "application", "shutdown.started"); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); @@ -1264,6 +1384,8 @@ async function openPluginWindow(pluginId: string, contributionId: string): Promi } }); pluginWindows.set(window, pluginId); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("will-navigate", (event, url) => { if (!url.startsWith(`canvastty-plugin://${pluginId}/`)) event.preventDefault(); diff --git a/src/main/ipc/registerBacklogIpc.ts b/src/main/ipc/registerBacklogIpc.ts new file mode 100644 index 000000000..ae4eaac13 --- /dev/null +++ b/src/main/ipc/registerBacklogIpc.ts @@ -0,0 +1,85 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron"; +import { BACKLOG_IPC, BACKLOG_EVENTS } from "../../shared/backlog.ts"; +import type { IpcRegistrar } from "./IpcReadinessGate.ts"; +import type { TerminalManager } from "../services/TerminalManager.ts"; + + +import type { OrchestrationTaskBoard, NewOrchestrationTask } from "../services/OrchestrationTaskBoard.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetLimits } from "../services/OrchestrationBudgetService.ts"; +import type { OrchestrationTemplateService } from "../services/OrchestrationTemplateService.ts"; + +interface Dependencies { + board:OrchestrationTaskBoard; budgets:OrchestrationBudgetService; flows:OrchestrationTemplateService; + taskRoot(id:string):{id:string;cwd:string;startedAt:number}; + terminals: TerminalManager; + getMainWindow(): BrowserWindow | null; +} +export function registerBacklogIpc(ipc: IpcRegistrar, deps: Dependencies): void { + const {terminals} = deps; + deps.board.subscribe(change=>{ + const window=deps.getMainWindow(); + if(!window || window.isDestroyed() || window.webContents.isDestroyed())return; + window.webContents.send(BACKLOG_EVENTS.taskBoardChanged,{rootSessionId:change.rootSessionId,revision:change.revision}); + }); + const trust = (event: IpcMainInvokeEvent) => { + const window = deps.getMainWindow(); + if (!window || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) throw new Error("Untrusted backlog caller."); + }; + const session = (id: unknown) => { + if (typeof id !== "string") throw new Error("Session id is required."); + const row = terminals.getMetadata(id); if (!row) throw new Error("Session no longer exists."); return row; + }; + const handle = (channel: string, fn: (...args: any[]) => unknown) => ipc.handle(channel, (event, ...args) => {trust(event);return fn(...args);}); + const root=(id:string)=>{session(id);return deps.taskRoot(id);}; + const masked=(value:unknown):unknown=>typeof value==="string" ? terminals.redactSecrets(value) : Array.isArray(value) ? value.map(masked) : value && typeof value==="object" ? Object.fromEntries(Object.entries(value).map(([key,row])=>[key,masked(row)])) : value; + handle(BACKLOG_IPC.tasks,(id:string)=>{const task=root(id);return deps.board.listTasks(task.cwd,task.id);}); + handle(BACKLOG_IPC.addTask,async(id:string,input:NewOrchestrationTask)=>{ + const task=root(id); + if(input?.ownerSessionId && root(input.ownerSessionId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.addTask(task.cwd,task.id,task.id,masked(input) as NewOrchestrationTask); + return row; + }); + handle(BACKLOG_IPC.assignTask,async(id:string,taskId:string,ownerId:string|null)=>{ + const task=root(id);if(ownerId!==null && root(ownerId).id!==task.id)throw new Error("Task owner must belong to this orchestration tree."); + const row=await deps.board.assignTask(task.cwd,task.id,taskId,ownerId,ownerId ? terminals.redactSecrets(session(ownerId).title) : null); + return row; + }); + handle(BACKLOG_IPC.closeTask,async(id:string,taskId:string)=>{ + const task=root(id),row=await deps.board.closeTask(task.cwd,task.id,taskId);return row; + }); + handle(BACKLOG_IPC.budget,(id:string)=>{const task=root(id);return deps.budgets.snapshot(task.id,task.startedAt);}); + handle(BACKLOG_IPC.setBudget,async(id:string,limits:OrchestrationBudgetLimits)=>{ + const task=root(id),row=await deps.budgets.setLimits(task.id,limits,task.startedAt);return row; + }); + handle(BACKLOG_IPC.clearBudget,async(id:string)=>{ + const task=root(id),row=await deps.budgets.clearLimits(task.id,task.startedAt);return row; + }); + handle(BACKLOG_IPC.saveTaskFlow,async(id:string,name:string)=>{ + const parent=session(id);if(parent.role!=="orchestrator")throw new Error("Choose an orchestrator to save its layout as a flow."); + if(typeof name!=="string" || !name.trim() || name.length>120)throw new Error("Flow name must contain 1–120 characters."); + const children=terminals.listMetadata().filter(row=>row.parentSessionId===id); + if(!children.length || children.length>16)throw new Error("A flow needs 1–16 direct subagents."); + const file=await deps.flows.save(root(id).cwd,{ + id:`task-${Date.now()}`,name:terminals.redactSecrets(name.trim()),description:"Saved from an orchestrator's card layout.", + roles:children.map((child,index)=>({id:`worker-${index+1}`,title:terminals.redactSecrets(child.title),count:1, + instruction:terminals.redactSecrets(`Work on the subtask ${child.title}. Report your result to the orchestrator.`), + ...(child.model ? {model:child.model} : {}),...(child.effort ? {effort:child.effort} : {})})), + finalStep:"Gather every worker's result, resolve disagreements, and deliver one final answer." + }); + return {file}; + }); + handle(BACKLOG_IPC.flows,async(projectRoot:string)=>masked(await deps.flows.list(projectRoot))); + handle(BACKLOG_IPC.previewFlow,async(projectRoot:string,flowId:string)=>masked(await deps.flows.preview(projectRoot,flowId))); + handle(BACKLOG_IPC.approveFlow,async(projectRoot:string,flowId:string,digest:string)=>deps.flows.approve(projectRoot,flowId,digest)); + handle(BACKLOG_IPC.flowInstructions,async(projectRoot:string,flowId:string)=>{ + const listing=await deps.flows.list(projectRoot),flow=listing.templates.find(row=>row.id===flowId);if(!flow)throw new Error("Flow no longer exists."); + return terminals.redactSecrets(deps.flows.instructions(flow)); + }); + handle(BACKLOG_IPC.sendInstructions, async (id:string,text:unknown) => { + session(id);if(typeof text!=="string" || text.length>32_000)throw new Error("Invalid instructions."); + const result=await terminals.deliverInput(id,`${terminals.redactSecrets(text)}\r`);if(!result.delivered)throw new Error(`Agent did not accept the instructions: ${result.reason}`); + }); + handle(BACKLOG_IPC.redactText, (text: unknown) => { + if (typeof text !== "string" || text.length > 1_000_000) throw new Error("Text is invalid or too large."); return terminals.redactSecrets(text); + }); +} diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 72a6c87e5..747b164f0 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,6 +1,6 @@ import { realpath } from "node:fs/promises"; import { app, BrowserWindow, clipboard, dialog, shell } from "electron"; -import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions } from "electron"; +import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions, WebContents } from "electron"; import type { AppSettings, AgentChatHistoryProviderId, @@ -75,6 +75,7 @@ interface Dependencies { getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): Promise | void; setCanvasNavigationShortcutCapture(active: boolean): void; + setCanvasNavigationTerminalEditFocus(contents: WebContents, active: boolean): void; setCanvasNavigationPointerBinding(input: CanvasNavigationPointerBindingInput): void; openPluginWindow(pluginId: string, contributionId: string): Promise; closePluginWindows(pluginId: string): void; @@ -211,6 +212,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { getMainWindow, applyBrowserSettings, setCanvasNavigationShortcutCapture, + setCanvasNavigationTerminalEditFocus, setCanvasNavigationPointerBinding, openPluginWindow, closePluginWindows, @@ -261,6 +263,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof active !== "boolean") return; setCanvasNavigationShortcutCapture(active); }); + ipcMain.on(IPC.canvasNavigationTerminalEditFocus, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + setCanvasNavigationTerminalEditFocus(event.sender, active); + }); ipcMain.on(IPC.canvasNavigationPointerBinding, (event, input: unknown) => { assertMainRenderer(event, getMainWindow); if (!isCanvasNavigationPointerBindingInput(input)) return; diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78a..d8ace8948 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -15,6 +15,9 @@ import { onDiskPath, otherSpellings } from "./onDiskPath.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; +import type { OrchestrationBudgetService } from "./OrchestrationBudgetService.ts"; +import { createDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.ts"; // Cards one parent may have in total, live or exited (the live limit is the person's setting). const MAX_CHILDREN_PER_PARENT = 16; @@ -32,6 +35,13 @@ const MAX_OBSERVE_CHARS = 8_192; const MAX_EXIT_WINDOW_CHARS = 16_384; const MAX_EXIT_LINES = 20; const CHILD_POSITION_STEP = { x: 60, y: 60 }; +const MAX_RETRY_COUNT = 2; +const MAX_RETRY_OUTPUT_BYTES = 4 * 1024; +const MAX_RETRY_OUTPUT_WINDOW_CHARS = 16 * 1024; +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const MAX_REVIEW_ANSWER_CHARS = 16 * 1024; +const REVIEW_TIMEOUT_MS = 180_000; +const REVIEW_STARTUP_QUIET_MS = 90_000; export interface SpawnAgentRequest { parentSessionId: string; @@ -46,6 +56,11 @@ export interface SpawnAgentRequest { /** The CLI's --model and reasoning effort for this subagent (checked by the launch for its CLI). */ model?: string; effort?: CreateSessionRequest["effort"]; + review?: boolean; + /** Optional known model reserved for this worker's read-only reviewer. */ + reviewModel?: string; + /** Internal-only role: suppresses all agent/plugin tools for a Plan-profile reviewer. */ + readOnlyReview?: boolean; } export interface AgentObservation { @@ -75,6 +90,8 @@ export interface AgentWaitResult { exitLines?: string; /** The final answer of the turn that ended (Codex, OpenCode subagents), masked. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; } export interface AgentWaitTiming { @@ -93,6 +110,19 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** Persistent task budgets. Only usage from a real provider/timeline source is counted. */ + budget?: Pick; + /** Returns a known model different from the worker's model. Null means a safe alternative is unavailable. */ + reviewModel?: (provider: AgentProviderId, workerModel: string | undefined) => string | null; + /** Actual CLI model metadata/configuration, used when the worker kept its CLI default. */ + workerModel?: (session:SessionMetadata) => string | null | Promise; + reviewCost?: (reviewerSessionId:string) => number|null; + /** Trusted host override: the returned diff must already be scoped to this worker and its launch. */ + reviewDiff?: (session: SessionMetadata) => Promise; + reviewTimeoutMs?: number; + /** How long an OpenCode reviewer may take to expose its input prompt before startup is refused. */ + reviewStartupMs?: number; + onReview?: (sessionId: string, review: AgentReviewResult) => void; } /** The longest wait one call may ask for (wait_for_agent's timeoutSeconds maximum). */ @@ -110,6 +140,19 @@ export interface AgentResult { exitLines?: string; /** The last turn's final answer as the agent reported it (Codex, OpenCode subagents), masked; absent otherwise. */ answer?: AgentAnswer; + /** Present when spawn_agent requested a read-only second-agent review. */ + review?: AgentReviewResult; +} + +export interface AgentReviewResult { + status: "pending" | "accepted" | "revise" | "rejected" | "unavailable"; + verdict?: "accept" | "revise" | "reject"; + notes?: string; + reason?: string; + reviewerSessionId?: string; + model?: string; + /** F-15 usage integrations can replace null when the provider reports a separate review cost. */ + costUsd: number | null; } export interface AgentAnswer { @@ -132,6 +175,20 @@ export class PromptNotDeliveredError extends Error { export class AgentControlService { private readonly terminals: TerminalManager; private readonly options: AgentControlOptions; + private readonly launchRequests = new Map(); + private readonly retryOrigins = new Map(); + private readonly retryCounts = new Map(); + private readonly pendingRetries = new Map(); + private readonly retryableQuiet = new Set(); + private readonly reviewRequested = new Set(); + private readonly readOnlyReviewers = new Set(); + private readonly reviews = new Map(); + private readonly reviewGenerations = new Map(); + private readonly reviewInputObservers = new Map void>(); + private readonly reviewPending = new Map>(); + private readonly reviewControllers = new Map(); + private readonly reviewAgents = new Map(); + private readonly reviewWatchers = new Map }>(); constructor(terminals: TerminalManager, options: AgentControlOptions = {}) { this.terminals = terminals; @@ -151,34 +208,38 @@ export class AgentControlService { const capabilities = PROVIDER_CAPABILITIES[request.provider]; if (!capabilities) throw new Error("Unknown agent provider."); if (!capabilities.send) throw new Error(`${request.provider} cannot receive prompts.`); + const account = this.subagentAccount(parent.id, request.provider, request.launchOptions); + if (account.launchOptions !== request.launchOptions) request = { ...request, launchOptions: account.launchOptions }; - const children = this.children(parent.id); - if (children.length >= MAX_CHILDREN_PER_PARENT) { - throw new DelegationRefusal(`Session ${parent.id} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); - } - const lineage = this.lineage(parent.id); - const root = lineage.at(-1)!; + const taskScope = this.taskRoot(parent.id); // What the request asks for first (its folder, its profile), then the person's limits. - const cwd = subagentFolder(root.cwd, parent.cwd, request.cwd); + const cwd = subagentFolder(taskScope.cwd, parent.cwd, request.cwd); if ("error" in cwd) throw new DelegationRefusal(cwd.error); + this.requireBudgetActive(parent.id); const profile = subagentProfile(parent.profile, request.provider, request.profile, this.containment()); if ("error" in profile) throw new DelegationRefusal(profile.error); - const limits = this.limits(); - // The parent is at level lineage.length - 1 below its top-level agent; the new card one further down. - if (lineage.length > limits.maxDepth) { - throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); - } - const live = this.descendants(root.id).filter((session) => session.exitCode === null).length; - if (live >= limits.maxSubagents) { - throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); - } + this.assertSpawnCapacity(parent.id); + return this.createSubagent(parent, request, cwd.cwd, profile.profile, signal); + } + + private createSubagent( + parent: SessionMetadata, + request: SpawnAgentRequest, + cwd: string, + profile: LaunchProfile, + signal?: AbortSignal + ): Promise { // A call cancelled before its agent starts launches nothing. if (signal?.aborted) return Promise.reject(spawnCanceled()); - const cascade = children.length; + // Recheck immediately before synchronous card creation, which reserves the live slot. + this.requireSession(parent.id); + this.requireBudgetActive(parent.id); + const { childrenCount } = this.assertSpawnCapacity(parent.id); + const cascade = childrenCount; const created = this.terminals.create({ provider: request.provider, - cwd: cwd.cwd, - profile: profile.profile, + cwd, + profile, position: { x: parent.position.x + CHILD_POSITION_STEP.x * (cascade + 1), y: parent.position.y + CHILD_POSITION_STEP.y * (cascade + 1) @@ -189,10 +250,31 @@ export class AgentControlService { ...(request.launchOptions !== undefined ? { launchOptions: request.launchOptions } : {}), ...(request.model !== undefined ? { model: request.model } : {}), ...(request.effort !== undefined ? { effort: request.effort } : {}) - }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent" }); + }, { ...(RESULT_CAPTURE_PROVIDERS.has(request.provider) ? { captureResult: true } : {}), origin: "subagent", captureReviewDiff: request.review === true }); + this.launchRequests.set(created.id, { ...request, cwd, profile }); + this.retryOrigins.set(created.id, created.id); + if (request.review === true) { + this.reviewRequested.add(created.id); + this.reviewInputObservers.set(created.id, this.terminals.observeInputWrites(created.id, (_data, submitted) => { + this.invalidateReview(created.id); + const generation = this.reviewGeneration(created.id); + this.retryableQuiet.delete(created.id); + if (!submitted) { + this.reviews.set(created.id, { status: "unavailable", reason: "Input has not been submitted as a new worker task.", costUsd: null }); + return; + } + return () => queueMicrotask(() => { + if (this.reviewRequested.has(created.id) && this.reviewGenerations.get(created.id) === generation) this.scheduleReview(created.id); + }); + })); + } + if (request.readOnlyReview === true) this.readOnlyReviewers.add(created.id); if (request.initialPrompt === undefined || request.initialPrompt.length === 0) return Promise.resolve(created); return this.deliver(created.id, `${request.initialPrompt}\r`, "prompt", signal) - .then(() => this.terminals.getMetadata(created.id) ?? created, (error: unknown) => { + .then(() => { + this.scheduleReview(created.id); + return this.terminals.getMetadata(created.id) ?? created; + }, (error: unknown) => { // Cancelled while it started: nobody receives its id, so the card is closed instead of left running. if (!signal?.aborted) throw error; try { this.terminals.dispose(created.id); } catch { /* it already ended */ } @@ -200,6 +282,32 @@ export class AgentControlService { }); } + /** + * Which model account a subagent of this parent runs on. A spawn that names no account inherits the orchestrator's + * own: it never falls back silently to the CLI's default model (for OpenCode that is OpenCode Zen, a third party). + * Naming one is explicit: "none" runs on the CLI's own sign-in, another account needs the plugin's delegable + * declaration (checked by the launch). An orchestrator without an account keeps the request as it is. + */ + subagentAccount(parentSessionId: string, provider: AgentProviderId, requested: SpawnAgentRequest["launchOptions"]): { + launchOptions: SpawnAgentRequest["launchOptions"]; + account: string | null; + source: "inherited" | "explicit" | "none"; + } { + const parent = this.requireSession(parentSessionId); + const named = requested?.[ACCOUNTS_PLUGIN_ID]; + if (named !== undefined) { + const account = selectedAccountId(requested); + return { launchOptions: requested, account: account === "default" ? null : account, source: "explicit" }; + } + const parentAccount = typeof this.terminals.modelAccountOf === "function" ? this.terminals.modelAccountOf(parent.id) : undefined; + if (!parentAccount) return { launchOptions: requested, account: null, source: "none" }; + if (parent.provider !== provider) { + throw new DelegationRefusal(`This orchestrator runs on model account ${parentAccount.slice(0, 40)} for ${parent.provider}; a ${provider} subagent cannot inherit it. ` + + `Pass launchOptions {"${ACCOUNTS_PLUGIN_ID}":{"account":""}} with a ${provider} account (list_accounts / pick_account), or {"account":"none"} to run it on ${provider}'s own sign-in and default model.`); + } + return { launchOptions: { ...(requested ?? {}), [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } }, account: parentAccount, source: "inherited" }; + } + /** The profile a subagent of this parent gets for this request (what spawn will use), or why it gets none. */ profileFor(parentSessionId: string, provider: AgentProviderId, requested?: unknown): { profile: LaunchProfile; inherited: boolean } | { error: string } { return subagentProfile(this.requireSession(parentSessionId).profile, provider, requested, this.containment()); @@ -213,6 +321,7 @@ export class AgentControlService { if (!capabilities.send) throw new Error(`${session.provider} cannot receive prompts.`); if (typeof text !== "string" || text.length === 0) throw new Error("Prompt text is required."); if (session.exitCode !== null) throw new Error("Agent session has already exited."); + this.assertInputAllowed(sessionId); return this.deliver(sessionId, submit ? `${text}\r` : text, "text", signal); } @@ -239,6 +348,62 @@ export class AgentControlService { return chain; } + /** The top-level orchestrator and project folder governing this session's shared task scope. */ + taskRoot(sessionId: string): { id: string; cwd: string; startedAt: number } { + const root = this.lineage(sessionId).at(-1)!; + const taskScope = root.taskScope; + return taskScope ? { ...taskScope } : { id: root.id, cwd: root.cwd, startedAt: root.startedAt }; + } + + /** Resolve every card against one host snapshot, sharing parent traversal and preserving continuation scopes. */ + taskRoots(sessions: readonly SessionMetadata[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])); + const resolved = new Map(); + const visiting = new Set(); + const resolveTaskRoot = (sessionId: string): { id: string; cwd: string; startedAt: number } | null => { + const cached = resolved.get(sessionId); + if (cached) return cached; + const session = byId.get(sessionId); + if (!session || visiting.has(sessionId)) return null; + visiting.add(sessionId); + const scope = (session.parentSessionId ? resolveTaskRoot(session.parentSessionId) : null) + ?? session.taskScope + ?? { id: session.id, cwd: session.cwd, startedAt: session.startedAt }; + visiting.delete(sessionId); + resolved.set(sessionId, scope); + return scope; + }; + for (const session of sessions) resolveTaskRoot(session.id); + return resolved; + } + + /** Called when TerminalManager removes a card; retries belonging to a live replacement retain their shared count. */ + forgetSession(sessionId: string): void { + this.reviewInputObservers.get(sessionId)?.(); + this.reviewInputObservers.delete(sessionId); + this.invalidateReview(sessionId); + this.reviewGenerations.delete(sessionId); + this.launchRequests.delete(sessionId); + this.retryableQuiet.delete(sessionId); + this.reviewRequested.delete(sessionId); + this.readOnlyReviewers.delete(sessionId); + this.reviews.delete(sessionId); + + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + this.retryOrigins.delete(sessionId); + if (!this.pendingRetries.has(sourceId) && ![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + + for (const [workerId, workerReviewerId] of this.reviewAgents) { + if (workerReviewerId !== sessionId) continue; + this.reviewAgents.delete(workerId); + } + } + + /** Apply the terminal manager's existing secret masks to host-only task context before it leaves the core. */ + maskText(text: string, maxChars: number): string { + return this.redactTail(text, Math.max(0, Math.min(65_536, maxChars))); + } + /** Every card below this one, at any depth. */ descendants(sessionId: string): SessionMetadata[] { const all = this.terminals.listMetadata(); @@ -265,6 +430,24 @@ export class AgentControlService { return DEFAULT_DELEGATION_LIMITS; } + private assertSpawnCapacity(parentSessionId: string, replacingSessionId?: string): { live: number; childrenCount: number } { + const childrenCount = this.children(parentSessionId).filter(session => session.id !== replacingSessionId).length; + if (childrenCount >= MAX_CHILDREN_PER_PARENT) { + throw new DelegationRefusal(`Session ${parentSessionId} already has ${MAX_CHILDREN_PER_PARENT} subagent cards; cancel_agent the finished ones first.`); + } + const lineage = this.lineage(parentSessionId); + const limits = this.limits(); + if (lineage.length > limits.maxDepth) { + throw new DelegationRefusal(`Subagents may nest at most ${limits.maxDepth} level${limits.maxDepth === 1 ? "" : "s"} deep below the agent the person started; this one would be level ${lineage.length}. The person sets this limit in Settings → Agents.`); + } + const live = this.descendants(lineage.at(-1)!.id) + .filter(session => session.id !== replacingSessionId && session.exitCode === null).length; + if (live >= limits.maxSubagents) { + throw new DelegationRefusal(`This orchestration already runs ${live} live subagent${live === 1 ? "" : "s"}, its limit (Settings → Agents, set by the person). Wait for one to finish or cancel_agent one first.`); + } + return { live, childrenCount }; + } + private containment(): boolean { try { return this.options.containment?.() === true; } catch { return false; } } @@ -326,28 +509,151 @@ export class AgentControlService { }; } + /** Gets a result with its review; tool calls defer unfinished reviews to stay within client deadlines. */ + async resultWithReview(sessionId: string, options: { deferReview?: boolean } = {}): Promise { + const current = this.result(sessionId); + if (!this.reviewRequested.has(sessionId)) return current; + const status = this.resultLifecycleStatus(sessionId) ?? current.status; + if (status !== "idle" && status !== "done" && status !== "failed" && current.exitCode === null) { + return { ...current, review: this.reviewWithCost(this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }) }; + } + if (options.deferReview) { + void this.ensureReview(sessionId); + return { ...current, review: this.reviewWithCost(this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }) }; + } + const generation = this.reviewGeneration(sessionId); + const review = await this.ensureReview(sessionId); + return { ...this.result(sessionId), review: this.reviewWithCost( + this.reviewGenerations.get(sessionId) === generation ? review : supersededReview()) }; + } + + private reviewWithCost(review:AgentReviewResult):AgentReviewResult{ + try{ + const cost=review.reviewerSessionId ? this.options.reviewCost?.(review.reviewerSessionId) : null; + return typeof cost==="number" && Number.isFinite(cost) && cost>=0 ? {...review,costUsd:cost} : review; + }catch{return review;} + } + + /** Retry is restricted to a failed or observed quiet agent. */ + async retry(sessionId: string, reason?: string, signal?: AbortSignal): Promise { + const session = this.requireSession(sessionId); + if (session.provider === "terminal") throw new Error("Plain terminals are not agents."); + const capabilities = PROVIDER_CAPABILITIES[session.provider as AgentProviderId]; + if (!capabilities?.send) throw new Error(`${session.provider} cannot receive prompts.`); + const failed = (session.exitCode !== null && session.exitCode !== 0) || session.status === "failed"; + if (!failed && !this.retryableQuiet.has(sessionId)) { + throw new DelegationRefusal("retry_agent works only for a failed or quiet subagent; a running agent must finish or be canceled first."); + } + const sourceId = this.retryOrigins.get(sessionId) ?? sessionId; + if (this.pendingRetries.has(sourceId)) throw new DelegationRefusal("A retry of this agent is already in progress."); + const count = this.retryCounts.get(sourceId) ?? 0; + if (count >= MAX_RETRY_COUNT) throw new DelegationRefusal(`This agent has already used its limit of ${MAX_RETRY_COUNT} retries.`); + const original = this.launchRequests.get(sessionId) ?? this.launchRequests.get(sourceId); + if (!original) throw new DelegationRefusal("CanvasTTY no longer has the original launch request for this agent, so it cannot retry it safely."); + const raw = this.terminals.readBuffer(sessionId).buffer; + const output = utf8Tail(this.redactTail(raw, MAX_RETRY_OUTPUT_WINDOW_CHARS), MAX_RETRY_OUTPUT_BYTES); + const failureReason = (typeof reason === "string" && reason.trim().slice(0, 500)) + || session.failureDetails + || (session.exitCode !== null ? `process exited with code ${session.exitCode}` : "the agent stopped producing output"); + const context = [ + "\n\nCanvasTTY retry context:", + `Failure reason: ${this.redactTail(failureReason, 500)}`, + output ? `Masked output tail (up to ${MAX_RETRY_OUTPUT_BYTES} UTF-8 bytes):\n${output}` : "No terminal output was available." + ].join("\n"); + const retryPrompt = `${original.initialPrompt ?? ""}${context}`; + // Reserve synchronously: concurrent tool calls must share the same finite allowance. + this.retryCounts.set(sourceId, count + 1); + this.pendingRetries.set(sourceId, (this.pendingRetries.get(sourceId) ?? 0) + 1); + try { + if (signal?.aborted) throw spawnCanceled(); + const parentId = session.parentSessionId ?? original.parentSessionId; + const validate = (): void => { + const parent = this.requireSession(parentId); + const taskScope = this.taskRoot(parentId); + const cwd = subagentFolder(taskScope.cwd, parent.cwd, session.cwd); + if ("error" in cwd) throw new DelegationRefusal(cwd.error); + const profile = subagentProfile(parent.profile, session.provider as AgentProviderId, session.profile, this.containment()); + if ("error" in profile) throw new DelegationRefusal(profile.error); + if (profile.profile !== session.profile) throw new DelegationRefusal("The original launch profile is no longer allowed; choose a new agent with an allowed profile."); + this.requireBudgetActive(parentId); + // This replaces the same card, and the manager waits for its old PTY to exit before starting a successor. + this.assertSpawnCapacity(parentId, sessionId); + }; + validate(); + const retried = await this.terminals.retryAgentLaunch(sessionId, retryPrompt, validate, signal, () => this.invalidateReview(sessionId)); + this.retryableQuiet.delete(sessionId); + this.scheduleReview(sessionId); + this.retryOrigins.set(retried.id, sourceId); + // Each attempt starts from the original request, with only this attempt's masked failure context appended. + this.launchRequests.set(retried.id, original); + if (original.review === true) this.reviewRequested.add(retried.id); + return retried; + } catch (error) { + this.retryCounts.set(sourceId, Math.max(0, (this.retryCounts.get(sourceId) ?? 1) - 1)); + throw error; + } finally { + const pending = (this.pendingRetries.get(sourceId) ?? 1) - 1; + if (pending > 0) this.pendingRetries.set(sourceId, pending); + else { + this.pendingRetries.delete(sourceId); + if (![...this.retryOrigins.values()].includes(sourceId)) this.retryCounts.delete(sourceId); + } + } + } + + taskBudget(sessionId: string): ReturnType | null { + if (!this.options.budget) return null; + const root = this.taskRoot(sessionId); + return this.options.budget.snapshot(root.id, root.startedAt); + } + + isReadOnlyReviewer(sessionId: string): boolean { + return this.readOnlyReviewers.has(sessionId); + } + + /** App-level PTY input gate. A hard task pause blocks input without signaling or killing any process. */ + assertInputAllowed(sessionId: string): void { + this.requireSession(sessionId); + this.requireBudgetActive(sessionId); + } + /** * Waits until the agent is at rest (idle, needs_approval, exited, or quiet when it reports no status), its card * closed, or the timeout passed. Only reads metadata and the output offset while it waits; the tail is read and * masked once, as it returns. Rejects with an AbortError once `signal` aborts. */ - async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal }): Promise { + async waitFor(sessionId: string, request: { timeoutMs: number; signal?: AbortSignal; quietMs?: number; deferReview?: boolean }): Promise { const { signal } = request; signal?.throwIfAborted(); const first = this.requireSession(sessionId); if (first.provider === "terminal") throw new Error("Plain terminals are not agents."); if (typeof request.timeoutMs !== "number" || !Number.isFinite(request.timeoutMs)) throw new Error("A wait timeout is required."); - const timing = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const base = this.options.waitTiming ?? AGENT_WAIT_TIMING; + const timing = request.quietMs !== undefined ? { ...base, quietMs: Math.max(base.quietMs, request.quietMs) } : base; const timeoutMs = Math.min(MAX_AGENT_WAIT_MS, Math.max(0, request.timeoutMs)); const started = Date.now(); - const answer = (session: SessionMetadata | null, reason: AgentWaitReason): AgentWaitResult => { + const answer = async (session: SessionMetadata | null, reason: AgentWaitReason): Promise => { const waitedMs = Date.now() - started; if (!session) return { sessionId, reason, exitCode: null, waitedMs, output: "" }; + if (reason === "quiet") this.retryableQuiet.add(sessionId); + else if (reason === "idle" || reason === "done" || reason === "failed") this.retryableQuiet.delete(sessionId); let observation: AgentObservation | null = null; try { observation = this.observe(sessionId); } catch { observation = null; } - const answer = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + const finalAnswer: AgentAnswer | null = reason === "timeout" || reason === "needs_approval" ? null : this.answer(sessionId); + let review: AgentReviewResult | undefined; + const generation = this.reviewGeneration(sessionId); + if (this.reviewRequested.has(sessionId) && (reason === "idle" || reason === "done" || reason === "failed" || reason === "quiet")) { + if (request.deferReview) { + const readiness = this.reviewReadiness(sessionId, reason === "quiet"); + if (!readiness) void this.ensureReview(sessionId, reason === "quiet"); + review = this.reviewWithCost(readiness ?? this.reviews.get(sessionId) ?? { status: "pending", costUsd: null }); + } else { + const result = await this.ensureReview(sessionId, reason === "quiet"); + review = this.reviewWithCost(this.reviewGenerations.get(sessionId) === generation ? result : supersededReview()); + } + } return { sessionId, reason, status: session.status, exitCode: session.exitCode, waitedMs, output: observation?.output ?? "", - ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(answer ? { answer } : {}) }; + ...(observation?.exitLines ? { exitLines: observation.exitLines } : {}), ...(finalAnswer ? { answer: finalAnswer } : {}), ...(review ? { review } : {}) }; }; let offset = this.outputOffset(sessionId); let changedAt = started; @@ -359,16 +665,17 @@ export class AgentControlService { if (current !== offset) { offset = current; changedAt = now; } const quietFor = now - changedAt; if (session.exitCode !== null) return answer(session, session.exitCode === 0 ? "done" : "failed"); - if (session.status === "needs_approval") return answer(session, "needs_approval"); + const status = this.resultLifecycleStatus(sessionId) ?? session.status; + if (status === "needs_approval") return answer(session, "needs_approval"); // After a prompt, an idle that no turn followed (the CLI's startup idle, or one reported before the turn began) // is not the answer: only an idle after a turn that started since that prompt is. A CLI that never reports its // turns still ends as "quiet" once its screen stops changing. const progress = this.turnProgress(sessionId); const awaitingTurn = progress !== null && progress.promptSent && !progress.turnStartedSincePrompt; - if (!awaitingTurn && (session.status === "idle" || session.status === "done" || session.status === "failed") && quietFor >= timing.settleMs) { - return answer(session, session.status); + if (!awaitingTurn && (status === "idle" || status === "done" || status === "failed") && quietFor >= timing.settleMs) { + return answer(session, status); } - if ((session.status === "unavailable" || awaitingTurn) && quietFor >= timing.quietMs) return answer(session, "quiet"); + if ((status === "unavailable" || awaitingTurn) && quietFor >= timing.quietMs) return answer(session, "quiet"); const waited = now - started; if (waited >= timeoutMs) return answer(session, "timeout"); await pause(Math.min(timing.checkMs, timeoutMs - waited), signal); @@ -380,6 +687,248 @@ export class AgentControlService { this.terminals.dispose(sessionId); } + private reviewGeneration(sessionId: string): object { + let generation = this.reviewGenerations.get(sessionId); + if (!generation) { generation = {}; this.reviewGenerations.set(sessionId, generation); } + return generation; + } + + private invalidateReview(sessionId: string): void { + const reviewerIds = new Set([this.reviews.get(sessionId)?.reviewerSessionId, this.reviewAgents.get(sessionId)]); + this.reviewGenerations.set(sessionId, {}); + this.reviews.delete(sessionId); + this.reviewWatchers.get(sessionId)?.controller.abort(); + this.reviewWatchers.delete(sessionId); + this.reviewControllers.get(sessionId)?.abort(); + this.reviewControllers.delete(sessionId); + this.reviewPending.delete(sessionId); + this.reviewAgents.delete(sessionId); + for (const reviewerId of reviewerIds) this.disposeReviewer(reviewerId); + } + + private disposeReviewer(reviewerId: string | undefined): void { + if (!reviewerId) return; + this.readOnlyReviewers.delete(reviewerId); + try { this.terminals.dispose(reviewerId); } catch { /* the reviewer may already have ended */ } + } + + private reviewReadiness(sessionId: string, quiet = false): AgentReviewResult | null { + // An old idle/answer can remain visible until the provider acknowledges the submitted input. + const progress = this.turnProgress(sessionId); + if (progress?.promptSent && !progress.turnStartedSincePrompt && !this.answer(sessionId)) { + return quiet || this.terminals.getMetadata(sessionId)?.exitCode !== null + ? { status: "unavailable", reason: "The provider did not report a new completed turn or final answer after the submitted input.", costUsd: null } + : { status: "pending", costUsd: null }; + } + return null; + } + + private async ensureReview(sessionId: string, quiet = false): Promise { + const readiness = this.reviewReadiness(sessionId, quiet); + if (readiness) return readiness; + const generation = this.reviewGeneration(sessionId); + const cached = this.reviews.get(sessionId); + if (cached) return cached; + const active = this.reviewPending.get(sessionId); + if (active) { + const result = await active; + return this.reviewGenerations.get(sessionId) === generation ? result : supersededReview(); + } + const controller = new AbortController(); + this.reviewControllers.set(sessionId, controller); + // Store the guarded promise, so every concurrent caller observes invalidation, not the raw verdict. + const pending = this.performReview(sessionId, controller.signal).catch((error: unknown): AgentReviewResult => ({ + status: "unavailable", + reason: this.redactTail(error instanceof Error ? error.message : "The reviewer failed.", 500), + costUsd: null + })).then(result => { + if (controller.signal.aborted || this.reviewGenerations.get(sessionId) !== generation) { + this.disposeReviewer(result.reviewerSessionId); + return supersededReview(); + } + this.reviews.set(sessionId, result); + try { this.options.onReview?.(sessionId, result); } catch { /* review observers cannot affect the result */ } + return result; + }).finally(() => { + if (this.reviewPending.get(sessionId) === pending) this.reviewPending.delete(sessionId); + if (this.reviewControllers.get(sessionId) === controller) this.reviewControllers.delete(sessionId); + }); + this.reviewPending.set(sessionId, pending); + const result = await pending; + return this.reviewGenerations.get(sessionId) === generation ? result : supersededReview(); + } + + private async performReview(sessionId: string, signal: AbortSignal): Promise { + const worker = this.requireSession(sessionId); + const request = this.launchRequests.get(sessionId); + if (!request) return { status: "unavailable", reason: "CanvasTTY no longer has the worker launch details.", costUsd: null }; + // A worker on a model account (Accounts plugin) is reviewed on that same account unless a review model was named: + // the agent's own sign-in may not exist, and only the account carries the key for this model. + const account = request.reviewModel ? null : selectedModelAccount(request.launchOptions); + if (!account && !request.reviewModel && !this.options.reviewModel) return { status: "unavailable", reason: "No different known reviewer model is available.", costUsd: null }; + let workerModel=worker.model ?? request.model; + if(!workerModel)try{workerModel=await this.options.workerModel?.(worker) ?? undefined;}catch{ /* Unknown remains unknown. */ } + if(signal.aborted)return {status:"unavailable",reason:"The worker session was removed before review.",costUsd:null}; + let model: string | null; + try { model = account ? `account:${account}` : request.reviewModel ?? this.options.reviewModel?.(worker.provider as AgentProviderId, workerModel) ?? null; } + catch { model = null; } + if (!model || (!account && workerModel && model === workerModel)) { + return { status: "unavailable", reason: "No known model different from the worker's model is available.", costUsd: null }; + } + const profile = this.profileFor(this.lineage(sessionId).at(-1)!.id, worker.provider as AgentProviderId, "plan"); + if ("error" in profile) return { status: "unavailable", reason: `A read-only Plan reviewer is unavailable: ${profile.error}`, costUsd: null }; + let diff: string; + try { + diff = this.options.reviewDiff ? await this.options.reviewDiff(worker) : await this.terminals.readReviewDiff(sessionId); + } catch (error) { + return { status: "unavailable", reason: `The worker diff could not be read: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null }; + } + if (signal.aborted) return { status: "unavailable", reason: "The worker session was removed before review.", costUsd: null }; + const maskedDiff = utf8Tail(this.redactTail(diff, MAX_REVIEW_DIFF_BYTES + 8_192), MAX_REVIEW_DIFF_BYTES); + const answer = this.answer(sessionId); + const maskedAnswer = answer ? this.redactTail(answer.text, MAX_REVIEW_ANSWER_CHARS) : "No final answer was available from this provider."; + const root = this.lineage(sessionId).at(-1)!; + const prompt = [ + "Review only the supplied answer and diff. The temporary review workspace also contains the same patch as review.diff. Do not inspect or modify project files; this is a read-only review.", + "Return one JSON object with exactly these fields: verdict (accept, revise, or reject) and findings (short actionable notes).", + "Use accept when no material issue is visible, revise when the author can address concrete issues, and reject when the result does not satisfy the request.", + "Worker answer:", maskedAnswer, + "Diff:", maskedDiff || "(No reviewable diff was found.)" + ].join("\n\n"); + let reviewer: SessionMetadata | undefined; + let keepReviewer = false; + let reviewerAccount: Awaited> | undefined; + let workspace: DiffOnlyReviewWorkspace; + try { + workspace = createDiffOnlyReviewWorkspace(maskedDiff); + } catch (error) { + return { status: "unavailable", reason: `The diff-only review workspace could not be prepared: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + try { + try { + reviewerAccount = account + ? await this.terminals.prepareReviewerAccount({ taskRootSessionId: root.id, provider: worker.provider as AgentProviderId, workspace, + launchOptions: { [MODEL_ACCOUNTS_PLUGIN_ID]: { account } } }) + : undefined; + if (signal.aborted) throw new Error("The worker session was removed before review."); + reviewer = this.terminals.createReadOnlyReviewer({ + taskRootSessionId: root.id, + provider: worker.provider as AgentProviderId, + workspace, + title: `Review: ${worker.title}`.slice(0, 80), + ...(reviewerAccount ? { account: reviewerAccount } : { model }) + }); + // Once creation succeeds, TerminalManager owns the contribution and cleans it up with the session. + reviewerAccount = undefined; + this.readOnlyReviewers.add(reviewer.id); + // OpenCode's fresh home has no conversation until submission; wait for its rendered input prompt or hook. + if (worker.provider === "opencode") { + const readyBy = Date.now() + (this.options.reviewStartupMs ?? REVIEW_STARTUP_QUIET_MS); + const ready = (): boolean => typeof this.terminals.inputReady === "function" + ? this.terminals.inputReady(reviewer!.id) + : (this.resultLifecycleStatus(reviewer!.id) ?? this.terminals.getMetadata(reviewer!.id)?.status) !== "unavailable"; + while (Date.now() < readyBy && !ready()) await pause(250, signal); + if (!ready()) throw new Error("The reviewer CLI did not expose its input prompt before the startup deadline."); + } + await this.deliver(reviewer.id, `${prompt}\r`, "prompt", signal); + } catch (error) { + return { status: "unavailable", reason: `The read-only reviewer could not start: ${error instanceof Error ? error.message : "unknown error"}`, costUsd: null, model }; + } + const reviewerSession = reviewer!; + this.reviewAgents.set(sessionId, reviewerSession.id); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed before review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + const waited = await this.waitFor(reviewerSession.id, { + timeoutMs: this.options.reviewTimeoutMs ?? REVIEW_TIMEOUT_MS, + // A reviewer starts its CLI in a fresh, empty home (OpenCode may fetch its provider package first) and reports + // no turn until then: ten silent seconds are not yet "stopped responding". + quietMs: REVIEW_STARTUP_QUIET_MS, + signal + }).catch((error: unknown) => ({ + sessionId: reviewerSession.id, reason: "failed" as const, exitCode: 1, waitedMs: 0, output: "", + exitLines: error instanceof Error ? this.redactTail(error.message, 500) : "review failed" + })); + if (signal.aborted) { + return { status: "unavailable", reason: "The worker session was removed during review.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + if (waited.reason === "timeout" || waited.reason === "quiet" || waited.reason === "failed" || !waited.answer) { + return { + status: "unavailable", + reason: waited.reason === "timeout" ? "The reviewer timed out." : waited.reason === "quiet" ? "The reviewer stopped responding without a readable verdict." : waited.exitLines ? `The reviewer failed before returning a verdict: ${this.redactTail(waited.exitLines, 500)}` : "The reviewer failed before returning a verdict.", + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } + const parsed = parseReview(waited.answer.text); + if (!parsed) { + return { status: "unavailable", reason: "The reviewer did not return a valid accept, revise, or reject verdict.", reviewerSessionId: reviewerSession.id, model, costUsd: null }; + } + keepReviewer = true; + return { + status: parsed.verdict === "accept" ? "accepted" : parsed.verdict === "revise" ? "revise" : "rejected", + verdict: parsed.verdict, + notes: [account ? "Reviewed on the worker's model account (the same model; name reviewModel for another)." : "", parsed.findings ? this.redactTail(parsed.findings, 8_000) : ""].filter(Boolean).join("\n") || undefined, + reviewerSessionId: reviewerSession.id, + model, + costUsd: null + }; + } finally { + if (reviewerAccount) await reviewerAccount.contribution.cleanup().catch(() => undefined); + if (reviewer && this.reviewAgents.get(sessionId) === reviewer.id) this.reviewAgents.delete(sessionId); + if (!keepReviewer) { + if (reviewer) try { this.terminals.dispose(reviewer.id); } catch { /* the task may already have removed it */ } + workspace.cleanup(); + if (reviewer) this.readOnlyReviewers.delete(reviewer.id); + } + } + } + + + private requireBudgetActive(sessionId: string): void { + if (!this.options.budget) return; + let snapshot: ReturnType; + const root = this.taskRoot(sessionId); + try { snapshot = this.options.budget.snapshot(root.id, root.startedAt); } + catch (error) { throw new DelegationRefusal(`Task budget state is unavailable; new subagent activity is paused. ${error instanceof Error ? error.message : ""}`); } + if (snapshot.paused) throw new DelegationRefusal(snapshot.reason ?? "This task's budget is exhausted. Increase or clear it in CanvasTTY before continuing."); + } + + private scheduleReview(sessionId: string): void { + if (!this.reviewRequested.has(sessionId) || this.reviewWatchers.has(sessionId)) return; + const controller = new AbortController(); + let watcher: { controller: AbortController; promise: Promise }; + const promise = this.monitorReview(sessionId, controller.signal).catch(() => undefined).finally(() => { + if (this.reviewWatchers.get(sessionId) === watcher) this.reviewWatchers.delete(sessionId); + }); + watcher = { controller, promise }; + this.reviewWatchers.set(sessionId, watcher); + } + + /** Waits for the worker's next completed turn and starts its review without requiring a result poll. */ + private async monitorReview(sessionId: string, signal: AbortSignal): Promise { + for (;;) { + if (signal.aborted) return; + const session = this.terminals.getMetadata(sessionId); + if (!session || !this.reviewRequested.has(sessionId)) return; + if (session.exitCode !== null) { + await this.ensureReview(sessionId); + return; + } + const result = await this.waitFor(sessionId, { timeoutMs: MAX_AGENT_WAIT_MS, signal }); + if (signal.aborted) return; + if (result.reason === "idle" || result.reason === "done" || result.reason === "failed" || result.reason === "quiet") { + const review = result.review ?? await this.ensureReview(sessionId, result.reason === "quiet"); + if (signal.aborted) return; + if (review.status !== "pending") return; + } + if (result.reason === "closed") return; + // Approval and quiet states need a later human input or another output sample; avoid spinning on either. + await pause(1_000, signal); + } + } + /** Through the terminal manager's one delivery rule: exactly once, into the launch that is starting now. */ private async deliver(sessionId: string, data: string, what: "prompt" | "text", signal?: AbortSignal): Promise { const delivery = await this.terminals.deliverInput(sessionId, data, undefined, signal); @@ -401,6 +950,10 @@ export class AgentControlService { return tail(typeof this.terminals.redactSecrets === "function" ? this.terminals.redactSecrets(text) : text, maxChars); } + private resultLifecycleStatus(sessionId: string): "idle" | "working" | "needs_approval" | null { + return typeof this.terminals.resultLifecycleState === "function" ? this.terminals.resultLifecycleState(sessionId) : null; + } + private turnProgress(sessionId: string): { promptSent: boolean; turnStartedSincePrompt: boolean } | null { try { return typeof this.terminals.turnProgress === "function" ? this.terminals.turnProgress(sessionId) : null; } catch { return null; } } @@ -518,6 +1071,38 @@ function tail(text: string, maxChars: number): string { return text.slice(text.length - maxChars); } +function utf8Tail(text: string, maxBytes: number): string { + const bytes = Buffer.from(text, "utf8"); + if (bytes.byteLength <= maxBytes) return text; + let value = bytes.subarray(bytes.byteLength - maxBytes).toString("utf8"); + // A UTF-8 byte window can begin in the middle of a code point; drop that partial character. + if (value.startsWith("\uFFFD")) value = value.slice(1); + return value; +} + +function parseReview(text: string): { verdict: "accept" | "revise" | "reject"; findings: string } | null { + const match = text.match(/\{[\s\S]*\}/u); + if (!match) return null; + let value: unknown; + try { value = JSON.parse(match[0]); } catch { return null; } + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + if (record.verdict !== "accept" && record.verdict !== "revise" && record.verdict !== "reject") return null; + const findings = typeof record.findings === "string" ? record.findings : typeof record.notes === "string" ? record.notes : ""; + return { verdict: record.verdict, findings: findings.slice(0, 8_000) }; +} + function spawnCanceled(): Error { return new DOMException("The spawn was canceled before its agent started.", "AbortError"); } + +const MODEL_ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; +/** The model account a worker was launched on through the Accounts plugin, if any. */ +function selectedModelAccount(launchOptions: SpawnAgentRequest["launchOptions"]): string | null { + const account = launchOptions?.[MODEL_ACCOUNTS_PLUGIN_ID]?.account; + return typeof account === "string" && account && account !== "none" ? account : null; +} + +function supersededReview(): AgentReviewResult { + return { status: "unavailable", reason: "The worker prompt changed or the session was removed before this review completed.", costUsd: null }; +} diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index 800482766..a8f15c67d 100644 --- a/src/main/services/CanvasNavigationOverride.ts +++ b/src/main/services/CanvasNavigationOverride.ts @@ -200,6 +200,8 @@ export class CanvasNavigationInputController { private readonly attachedContents = new Set(); private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; private menuShortcutContents: WebContents | null = null; + /** Contents whose renderer reported keyboard focus in a terminal surface or shortcut recorder. */ + private readonly terminalEditFocus = new WeakSet(); constructor( bindings: CanvasNavigationBindings, @@ -233,6 +235,19 @@ export class CanvasNavigationInputController { }); } + /** + * The renderer reports whether a terminal surface or shortcut recorder has keyboard focus. Both consume + * Command+C/V/A themselves, so those shortcuts bypass the Edit menu. The terminal-named API covers both; + * ordinary fields and plugin pages keep the menu's native Copy, Paste and Select All. + */ + setTerminalEditFocus(contents: WebContents, focused: boolean): void { + if (focused) this.terminalEditFocus.add(contents); + else { + this.terminalEditFocus.delete(contents); + if (this.menuShortcutContents === contents) this.releaseMenuShortcuts(); + } + } + setBindings(bindings: CanvasNavigationBindings): void { this.releaseMenuShortcuts(); const previous = this.state(); @@ -299,6 +314,7 @@ export class CanvasNavigationInputController { const wheelTransition = this.wheelTracker.update(keyboardInput); const navigationTransition = this.navigationTracker.update(keyboardInput); const macEditShortcut = options.captureMacEditShortcuts === true + && this.terminalEditFocus.has(contents) && topFrameFocused(contents) && input.type === "keyDown" && input.meta && !input.control && !input.alt && !input.shift && (input.code === "KeyA" || input.code === "KeyC" || input.code === "KeyV"); @@ -374,3 +390,10 @@ export class CanvasNavigationInputController { } } + +/** An iframe (a plugin page) with focus is never a terminal surface; contents without the property count as top. */ +function topFrameFocused(contents: WebContents): boolean { + const focused = (contents as { focusedFrame?: unknown }).focusedFrame; + const top = (contents as { mainFrame?: unknown }).mainFrame; + return focused === undefined || focused === null || top === undefined || focused === top; +} diff --git a/src/main/services/DiffOnlyReviewWorkspace.ts b/src/main/services/DiffOnlyReviewWorkspace.ts new file mode 100644 index 000000000..99ac35213 --- /dev/null +++ b/src/main/services/DiffOnlyReviewWorkspace.ts @@ -0,0 +1,66 @@ +import { chmodSync, mkdtempSync, realpathSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const MAX_REVIEW_DIFF_BYTES = 64 * 1024; +const ownedWorkspaces = new WeakSet(); + +/** + * POSIX keeps the exact owner-only modes. Windows has no owner/group/other bits: chmod only toggles the read-only + * attribute, which stat reports as the absence of every write bit, so only that part of the mode is checked there. + */ +function hasReviewMode(mode: number, expected: number, platform: NodeJS.Platform): boolean { + return platform === "win32" ? (mode & 0o222) === 0 : (mode & 0o777) === expected; +} + +/** A private, host-created directory whose only source file is the host-reviewed patch. */ +export interface DiffOnlyReviewWorkspace { + readonly directory: string; + readonly diffPath: string; + cleanup(): void; +} + +export function createDiffOnlyReviewWorkspace(diff: string): DiffOnlyReviewWorkspace { + if (typeof diff !== "string" || Buffer.byteLength(diff, "utf8") > MAX_REVIEW_DIFF_BYTES) { + throw new Error("The reviewed diff exceeds the temporary review workspace limit."); + } + const tempRoot = realpathSync(tmpdir()); + const directory = realpathSync(mkdtempSync(join(tempRoot, "canvastty-review-"))); + chmodSync(directory, 0o700); + const diffPath = join(directory, "review.diff"); + try { + writeFileSync(diffPath, diff, { encoding: "utf8", flag: "wx", mode: 0o400 }); + chmodSync(diffPath, 0o400); + chmodSync(directory, 0o500); + } catch (error) { + chmodSync(directory, 0o700); + rmSync(directory, { recursive: true, force: true }); + throw error; + } + const workspace: DiffOnlyReviewWorkspace = Object.freeze({ + directory, + diffPath, + cleanup() { + try { chmodSync(directory, 0o700); } catch { /* already removed */ } + rmSync(directory, { recursive: true, force: true }); + ownedWorkspaces.delete(workspace); + } + }); + ownedWorkspaces.add(workspace); + return workspace; +} + +/** TerminalManager accepts only the exact object created here, not paths forged through session metadata. */ +export function isHostOwnedDiffOnlyReviewWorkspace(value: unknown, platform: NodeJS.Platform = process.platform): value is DiffOnlyReviewWorkspace { + if (!value || typeof value !== "object" || !ownedWorkspaces.has(value)) return false; + const workspace = value as DiffOnlyReviewWorkspace; + try { + if (realpathSync(workspace.directory) !== workspace.directory || realpathSync(workspace.diffPath) !== workspace.diffPath + || workspace.diffPath !== join(workspace.directory, "review.diff")) return false; + const directory = statSync(workspace.directory); + const diff = statSync(workspace.diffPath); + return directory.isDirectory() && hasReviewMode(directory.mode, 0o500, platform) + && diff.isFile() && hasReviewMode(diff.mode, 0o400, platform) + && readdirSync(workspace.directory).length === 1 && readdirSync(workspace.directory)[0] === "review.diff"; + } catch { return false; } +} diff --git a/src/main/services/GitCheckpoints.ts b/src/main/services/GitCheckpoints.ts new file mode 100644 index 000000000..223afbedd --- /dev/null +++ b/src/main/services/GitCheckpoints.ts @@ -0,0 +1,74 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath, lstat, open, mkdir, mkdtemp, readdir, readFile, writeFile, rename, rm, type FileHandle } from "node:fs/promises"; +import { constants, createReadStream } from "node:fs"; +import { resolve, relative, isAbsolute, sep, dirname, join } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +const exec = promisify(execFile); + +/** Bounded, redacted working diff supplied to an automatic reviewer. */ +export class GitCheckpoints { + private readonly redact:(text:string)=>string; + constructor(redact: (text:string)=>string) {this.redact=redact;} + private async git(cwd: string, args: string[]): Promise { + const env = { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))), + GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" }; + const safe = ["--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "diff.external="]; + // Snapshotting runs outside the agent process; never execute repository-provided clean filters. + const configured = await exec("git", [...safe, "-C", cwd, "config", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"], {env, timeout: 5000}).catch((error: unknown) => { + if ((error as {code?: unknown}).code === 1) return {stdout: ""}; throw error; + }); + for (const line of configured.stdout.split("\n")) { + const key = line.split(/\s/, 1)[0]; + if (/^filter\..*\.(clean|smudge|process|required)$/.test(key)) safe.push("-c", `${key}=${key.endsWith(".required") ? "false" : ""}`); + } + return (await exec("git", [...safe, "-C", cwd, ...args], {env, timeout: 15_000, maxBuffer: 2 * 1024 * 1024})).stdout.trimEnd(); + } + async available(cwd: string): Promise { + try { return await realpath(await this.git(cwd, ["rev-parse", "--show-toplevel"])) === await realpath(cwd); } + catch { return false; } + } + async workingDiff(cwd:string, baselineHead?:string):Promise { + if (baselineHead !== undefined && !/^[a-f0-9]{40,64}$/u.test(baselineHead)) throw new Error("Invalid review baseline commit."); + const parts = [await this.git(cwd,["diff","--no-ext-diff","--no-textconv","--unified=3",baselineHead ?? "HEAD","--"])]; + const root = await realpath(cwd); + const paths = (await this.git(cwd,["ls-files","--others","--exclude-standard","-z"])).split("\0").filter(Boolean); + let bytes = Buffer.byteLength(parts[0]), omitted = 0; + for (const path of paths) { + if (bytes >= 512 * 1024 || /[\u0000-\u001F\u007F]/u.test(path)) { omitted++; continue; } + let file; + try { + const full = resolve(root,path), target = await realpath(full), within = relative(root,target); + const metadata = await lstat(full); + if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`) || !metadata.isFile() || metadata.size > 32 * 1024) { omitted++; continue; } + file = await open(full, constants.O_RDONLY | constants.O_NOFOLLOW); + const opened = await file.stat(); + if (!opened.isFile() || opened.ino !== metadata.ino || opened.dev !== metadata.dev || opened.size > 32 * 1024) { omitted++; continue; } + const raw = await readBoundedUntrackedFile(file); + const after = await file.stat(); + if (raw.includes(0) || raw.length > 32 * 1024 || after.size !== opened.size + || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) { omitted++; continue; } + const text = new TextDecoder("utf8", {fatal:true}).decode(raw), lines = text.endsWith("\n") ? text.slice(0,-1).split("\n") : text.split("\n"); + if (!text) lines.length = 0; + const quoted = JSON.stringify(path); + const hunk = `diff --git ${JSON.stringify(`a/${path}`)} ${JSON.stringify(`b/${path}`)}\nnew file mode ${opened.mode & 0o111 ? "100755" : "100644"}\n--- /dev/null\n+++ ${quoted}\n@@ -0,0 +${lines.length ? 1 : 0},${lines.length} @@\n${lines.map(line=>`+${line}`).join("\n")}\n${text && !text.endsWith("\n") ? "\\ No newline at end of file\n" : ""}`; + parts.push(hunk); bytes += Buffer.byteLength(hunk); + } catch { omitted++; } + finally { await file?.close(); } + } + if (omitted) parts.push(`[${omitted} untracked file(s) omitted: binary, oversized, unsafe, or unavailable.]`); + // Mask the complete collected text before taking the bounded tail consumed by the reviewer. + return this.redact(parts.join("\n")).slice(-512 * 1024); + } +} +/** A file can grow after stat; stop reading at the cap before allocating an oversized review input. */ +async function readBoundedUntrackedFile(file: FileHandle): Promise { + const buffer = Buffer.alloc(32 * 1024 + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await file.read(buffer, length, buffer.length - length, length); + if (bytesRead === 0) break; + length += bytesRead; + } + return buffer.subarray(0, length); +} diff --git a/src/main/services/LaunchPipeline.ts b/src/main/services/LaunchPipeline.ts index 439370a56..d9608e3e1 100644 --- a/src/main/services/LaunchPipeline.ts +++ b/src/main/services/LaunchPipeline.ts @@ -13,6 +13,7 @@ import { claudeCoreSettingsKey, coreOwnedLaunchArgument, parseInlineSettings } f import { MAX_PLUGIN_SLOT_BYTES } from "./TerminalSessionStore.ts"; import { LaunchRefusal } from "./launchRefusal.ts"; import { MAX_INSPECTED_CONFIG_BYTES, parseJsonc, readInspectedFile } from "./inspectedConfig.ts"; +import { selectedAccountHome, selectedAccountId, ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; /** A trusted plugin service that declared launch options (PluginManager.launchContributors). */ export interface LaunchContributor { @@ -20,6 +21,8 @@ export interface LaunchContributor { pluginName: string; serviceId: string; launch: PluginServiceLaunch; + /** Host-computed private data directory. Never sent to the plugin or accepted from its RPC response. */ + dataDir?: string; /** The plugin holds the `secrets` permission, so `secretEnv` may name its secrets. */ secrets: boolean; } @@ -41,6 +44,8 @@ export interface LaunchContext { profile: LaunchProfileId; role: SessionRole; cwd: string; + /** Original person-selected task project, retained when an environment relocates cwd. Host-owned. */ + projectRoot?: string; parentSessionId?: string; /** True when the app is bringing back a saved session. */ restoring: boolean; @@ -73,6 +78,9 @@ export type PreparedLaunch = envSources: Record; /** A contributor runs the agent on another model than its vendor's: "auto" becomes accept-edits. */ thirdPartyModel: boolean; + /** Host-derived selected Accounts CLI home, passed transiently to AgentIsolation for exact-path validation. */ + accountHome?: string; + accountId?:string; cleanup(): Promise; } | { ok: false; reason: string }; @@ -145,7 +153,8 @@ export class LaunchPipeline { * Checks launcher values against each plugin's declared fields and fills defaults. * Throws with a person-readable reason; returns undefined when nothing was chosen. */ - normalizeOptions(provider: ProviderId, candidate: unknown, context?: { delegated?: boolean }): Record | undefined { + normalizeOptions(provider: ProviderId, candidate: unknown, + context?: { delegated?: boolean; inherited?: Readonly>>> }): Record | undefined { if (candidate === undefined) return undefined; if (!isRecord(candidate) || Object.keys(candidate).length > MAX_OPTION_PLUGINS) throw new Error("Launch options are invalid."); if (Object.keys(candidate).length === 0) return undefined; @@ -156,7 +165,7 @@ export class LaunchPipeline { const contributor = contributors.get(pluginId); if (!contributor) throw new Error(unavailableReason(pluginId)); // An orchestrator picks options for its subagents only where the plugin said that is safe. - if (context?.delegated && contributor.launch.delegable !== true) { + if (context?.delegated && contributor.launch.delegable !== true && !sameAsInherited(raw, context.inherited?.[pluginId])) { throw new LaunchRefusal(`${contributor.pluginName} has not declared its launch options safe for an orchestrator to choose; only the person chooses them, in the launcher.`); } const { appliesTo, fields } = contributor.launch; @@ -267,12 +276,19 @@ export class LaunchPipeline { const args: string[] = []; const secrets: string[] = []; let thirdPartyModel = false; + let accountHome: string | undefined; + let accountId:string|undefined; for (const answer of answers) { if ("refuse" in answer) return refuse(answer.refuse); } for (const answer of answers as Array<{ contributor: LaunchContributor; contribution: Contribution }>) { const { contributor, contribution } = answer; const name = contributor.pluginName; + if(contribution.accountId!==undefined) { + if(contributor.pluginId!==ACCOUNTS_PLUGIN_ID)return refuse(`${name} cannot attribute the selected model account.`); + if(contribution.accountId!==selectedAccountId(context.options))return refuse(`${name} returned an account attribution that differs from the selected account.`); + accountId=contribution.accountId; + } if (context.provider === "terminal" && contribution.args.length > 0) { return refuse(`${name} added arguments to a plain terminal, which takes none.`); } @@ -312,7 +328,12 @@ export class LaunchPipeline { for (const [key, value] of Object.entries(contribution.env)) { const conflict = claim(key); if (conflict) return refuse(conflict); - env[key] = expand(value); + const expanded = expand(value); + env[key] = expanded; + // Only the host's Accounts service may reopen its selected provider home below plugin-data. This value is + // derived from the trusted contributor record, not a plugin-returned grant or an inherited environment. + accountHome = selectedAccountHome(context.provider, contributor.pluginId, contributor.dataDir, + context.options[contributor.pluginId]?.account, { [key]: expanded }) ?? accountHome; } for (const [key, secretKey] of Object.entries(contribution.secretEnv)) { const conflict = claim(key); @@ -327,7 +348,8 @@ export class LaunchPipeline { args.push(...contributedArgs.map(expand)); thirdPartyModel ||= contribution.thirdPartyModel === true; } - return { ok: true, env, args, secrets, envSources, thirdPartyModel, cleanup }; + return { ok: true, env, args, secrets, envSources, thirdPartyModel, ...(accountHome ? { accountHome } : {}), ...(accountId ? {accountId} : {}), + cleanup }; } private async ask( @@ -370,6 +392,7 @@ interface Contribution { files: Array<{ relPath: string; content: string }>; /** Only ever restricts (auto → accept-edits), so a launch policy may set it too. */ thirdPartyModel?: boolean; + accountId?:string; refuse?: string; } @@ -377,7 +400,7 @@ interface Contribution { function validContribution(value: unknown): Contribution | string { if (value === null) return { env: {}, secretEnv: {}, args: [], files: [] }; if (!isRecord(value)) return "not an object"; - const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse"].includes(key)); + const unknown = Object.keys(value).find((key) => !["env", "secretEnv", "args", "files", "thirdPartyModel", "refuse", "accountId"].includes(key)); if (unknown) return `unknown key ${unknown.slice(0, 40)}`; if (value.refuse !== undefined) { const reason = isRecord(value.refuse) ? value.refuse.reason : undefined; @@ -385,6 +408,7 @@ function validContribution(value: unknown): Contribution | string { return { env: {}, secretEnv: {}, args: [], files: [], refuse: reason.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, MAX_REASON) }; } if (value.thirdPartyModel !== undefined && typeof value.thirdPartyModel !== "boolean") return "thirdPartyModel must be true or false"; + if(value.accountId!==undefined && (typeof value.accountId!=="string" || !/^[\w-]{1,80}$/.test(value.accountId)))return "accountId must be a bounded account identifier"; const env = stringMap(value.env, MAX_ENV, "env"); if (typeof env === "string") return env; for (const [key, entry] of Object.entries(env)) { @@ -419,7 +443,7 @@ function validContribution(value: unknown): Contribution | string { if (bytes > MAX_FILES_BYTES) return "files exceed 256 KB"; files.push({ relPath: file.relPath, content: file.content }); } - return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}) }; + return { env, secretEnv, args: args as string[], files, ...(value.thirdPartyModel === true ? { thirdPartyModel: true } : {}),...(typeof value.accountId==="string" ? {accountId:value.accountId} : {}) }; } /** @@ -575,3 +599,10 @@ export function errorText(error: unknown): string { export function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } + +/** The orchestrator's own choice repeated for its subagent: only values it inherited, each one the same. */ +function sameAsInherited(raw: unknown, inherited: Readonly> | undefined): boolean { + if (!inherited || !isRecord(raw)) return false; + const keys = Object.keys(raw); + return keys.length > 0 && keys.every((key) => Object.hasOwn(inherited, key) && inherited[key] === raw[key]); +} diff --git a/src/main/services/OrchestrationBudgetService.ts b/src/main/services/OrchestrationBudgetService.ts new file mode 100644 index 000000000..cdb4da8f5 --- /dev/null +++ b/src/main/services/OrchestrationBudgetService.ts @@ -0,0 +1,397 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +export interface OrchestrationBudgetLimits { + tokens: number | null; + costUsd: number | null; + durationMs: number | null; +} + +export interface OrchestrationBudgetUsage { + /** Null means the provider or timeline source does not report token usage. */ + tokens: number | null; + /** Null means no cost data is available. It is never estimated from token counts. */ + costUsd: number | null; + /** Time is measured from the root orchestrator's start and includes every descendant. */ + durationMs: number | null; + reportedAt?: number; +} + +export interface OrchestrationBudgetSnapshot { + rootSessionId: string; + limits: OrchestrationBudgetLimits; + usage: OrchestrationBudgetUsage; + remaining: OrchestrationBudgetUsage; + warning: boolean; + paused: boolean; + reason?: string; + data: { tokens: "available" | "none"; costUsd: "available" | "partial" | "none"; duration: "available" | "none" }; +} + +interface StoredBudget { + startedAt?: number; + sessions?:Record; + limits: OrchestrationBudgetLimits; + tokens: number | null; + costUsd: number | null; + reportedAt: number | null; + usageSource?:"direct"|"session-ledger"; + warned: boolean; + paused: boolean; + pauseCause?:"limit"|"cost-data"; + enforcementFailure?:string; +} + +interface StoreState { version: 1; budgets: Record } +interface BudgetCostStatus { incomplete: boolean; provisional: boolean } +interface BudgetCallbacks { + onWarning?(snapshot: OrchestrationBudgetSnapshot): void; + onPause?(snapshot: OrchestrationBudgetSnapshot): void; + onChange?(snapshot: OrchestrationBudgetSnapshot): void; +} + +const EMPTY_LIMITS: OrchestrationBudgetLimits = { tokens: null, costUsd: null, durationMs: null }; +const EMPTY_STATE: StoreState = { version: 1, budgets: {} }; +const MAX_ROOTS = 1_024; + +/** + * Persistent task-wide budgets. Token and cost usage must be supplied by a real provider/timeline source; unknown + * values remain null and never masquerade as zero. Budget controls are intentionally absent from the agent tools. + */ +export class OrchestrationBudgetService { + private readonly path: string; + private readonly callbacks: BudgetCallbacks; + private state: StoreState = structuredClone(EMPTY_STATE); + private loaded = false; + private writeQueue: Promise = Promise.resolve(); + private readonly listeners = new Set<(snapshot: OrchestrationBudgetSnapshot) => void>(); + private readonly deadlines = new Map>(); + + constructor(storagePath: string, callbacks: BudgetCallbacks = {}) { + this.path = resolve(storagePath); + this.callbacks = callbacks; + } + + async load(): Promise { + try { + const text = await readFile(this.path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 1024 * 1024) throw new Error("Budget store exceeds 1 MB."); + const candidate: unknown = JSON.parse(text); + if (!isStore(candidate)) throw new Error("Budget store has an invalid format."); + this.state = candidate; + } catch (error) { + if (!isMissing(error)) throw new Error(`Could not load orchestration budgets: ${errorText(error)}`); + this.state = structuredClone(EMPTY_STATE); + } + this.loaded = true; + for(const [id,record] of Object.entries(this.state.budgets))if(record.startedAt!==undefined)this.snapshot(id,record.startedAt); + } + + async flush():Promise{await this.writeQueue;} + dispose():void {for(const timer of this.deadlines.values())clearTimeout(timer);this.deadlines.clear();} + snapshots():OrchestrationBudgetSnapshot[] {this.assertLoaded();return Object.keys(this.state.budgets).map(id=>this.snapshot(id));} + hasLimits(rootSessionId:string):boolean { + this.assertLoaded();requireId(rootSessionId); + const limits=this.state.budgets[rootSessionId]?.limits ?? EMPTY_LIMITS; + return limits.tokens!==null || limits.costUsd!==null || limits.durationMs!==null; + } + setEnforcementFailure(rootSessionId:string,failure?:string):void { + const record=this.record(rootSessionId); + if(record.enforcementFailure===failure)return; + if(failure){record.enforcementFailure=failure.slice(0,500);record.paused=true;delete record.pauseCause;} + else delete record.enforcementFailure; + this.persistQuietly(); + } + + subscribe(listener: (snapshot: OrchestrationBudgetSnapshot) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Called only by the host UI/controller after the person changes limits. */ + async setLimits(rootSessionId: string, limits: OrchestrationBudgetLimits, rootStartedAt?: number): Promise { + this.assertLoaded(); + requireId(rootSessionId); + const normalized = normalizeLimits(limits); + const record = this.record(rootSessionId); + delete record.enforcementFailure; + record.limits = normalized; + const current = this.snapshot(rootSessionId, rootStartedAt); + const overLimit=isOver(current),unknownCost=record.limits.costUsd!==null && budgetCostStatus(record).incomplete; + if (!overLimit && !unknownCost) { + record.paused = false; + delete record.pauseCause; + record.warned = false; + } else { + record.paused = true; + record.pauseCause=overLimit ? "limit" : "cost-data"; + } + await this.persist(); + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.emit(updated); + return updated; + } + + /** A single action that removes every limit and releases the task from a budget pause. */ + async clearLimits(rootSessionId: string, rootStartedAt?: number): Promise { + return this.setLimits(rootSessionId, { ...EMPTY_LIMITS }, rootStartedAt); + } + + /** Host-only callback for real CLI usage. Values are cumulative for the whole orchestration tree. */ + recordUsage(rootSessionId: string, usage: Pick, rootStartedAt?: number): OrchestrationBudgetSnapshot { + return this.writeUsage(rootSessionId,usage,rootStartedAt,"direct"); + } + + private writeUsage(rootSessionId:string,usage:Pick,rootStartedAt:number|undefined,source:"direct"|"session-ledger"):OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.record(rootSessionId); + record.tokens = normalizeUsage(usage.tokens, "tokens"); + record.costUsd = normalizeUsage(usage.costUsd, "costUsd"); + record.reportedAt = Date.now(); + record.usageSource=source; + const updated = this.snapshot(rootSessionId, rootStartedAt); + this.persistQuietly(); + this.emit(updated); + return updated; + } + + /** Individual source counters remain in the task ledger even after a card closes or an account hands off. */ + recordSessionUsage(rootSessionId:string,sessionId:string,usage:{tokens:number|null;costUsd:number|null},options:{rootStartedAt?:number;replaceCounterIds?:readonly string[];provisional?:boolean}={}):OrchestrationBudgetSnapshot { + this.assertLoaded();requireId(rootSessionId);requireId(sessionId); + const {rootStartedAt}=options; + const record=this.record(rootSessionId),sessions=record.sessions ?? (record.sessions={}); + // Replace only the host's pre-report placeholder; real conversation counters, + // including unknown prices from earlier conversations, remain in the ledger. + let removedPending=false; + for(const id of options.replaceCounterIds ?? []){ + requireId(id); + if(id!==sessionId && Object.hasOwn(sessions,id)){delete sessions[id];removedPending=true;} + } + const tokens=normalizeUsage(usage.tokens,"tokens"),costUsd=normalizeUsage(usage.costUsd,"costUsd"),previous=sessions[sessionId],provisional=options.provisional===true; + if(provisional && (tokens!==null || costUsd!==null))throw new Error("Provisional task usage cannot include observed tokens or cost."); + const legacyPending=provisional && previous!==undefined && !previous.provisional && previous.tokens===null && previous.costUsd===null; + if(provisional && previous && !previous.provisional && !legacyPending)return this.snapshot(rootSessionId,rootStartedAt); + if(legacyPending && record.usageSource===undefined)record.usageSource="session-ledger"; + const next={tokens:tokens===null ? previous?.tokens ?? null : Math.max(previous?.tokens ?? 0,tokens), + costUsd,...(provisional ? {provisional:true} : {})}; + if(!removedPending && previous && previous.tokens===next.tokens && previous.costUsd===next.costUsd && (previous.provisional===true)===provisional)return this.snapshot(rootSessionId,rootStartedAt); + if(!previous && Object.keys(sessions).length>=2048)throw new Error("Task usage ledger holds at most 2048 agents."); + sessions[sessionId]=next; + let hasObserved=false,knownTokens=0,tokenSum=0,hasCost=false,costSum=0; + for(const row of Object.values(sessions))if(row.provisional!==true) { + hasObserved=true; + if(row.tokens!==null){knownTokens++;tokenSum+=row.tokens;} + if(row.costUsd!==null)hasCost=true; + costSum+=row.costUsd ?? 0; + } + if(!hasObserved){this.persistQuietly();const updated=this.snapshot(rootSessionId,rootStartedAt);this.emit(updated);return updated;} + return this.writeUsage(rootSessionId,{tokens:knownTokens ? tokenSum : null,costUsd:hasCost ? costSum : null},rootStartedAt,"session-ledger"); + } + + /** Called before each spawn/send; also trips a time limit even if the CLI has emitted no further usage. */ + snapshot(rootSessionId: string, rootStartedAt?: number): OrchestrationBudgetSnapshot { + this.assertLoaded(); + requireId(rootSessionId); + const record = this.state.budgets[rootSessionId] ?? emptyBudget(); + if(record.startedAt===undefined && Number.isFinite(rootStartedAt)) { + record.startedAt=Number(rootStartedAt); + if(this.state.budgets[rootSessionId])this.persistQuietly(); + } + const start=record.startedAt ?? rootStartedAt; + const durationMs = Number.isFinite(start) ? Math.max(0, Date.now() - Number(start)) : null; + const usage: OrchestrationBudgetUsage = { + tokens: record.tokens, + costUsd: record.costUsd, + durationMs, + ...(record.reportedAt !== null ? { reportedAt: record.reportedAt } : {}) + }; + const remaining: OrchestrationBudgetUsage = { + tokens: difference(record.limits.tokens, usage.tokens), + costUsd: difference(record.limits.costUsd, usage.costUsd), + durationMs: difference(record.limits.durationMs, usage.durationMs) + }; + const thresholdView = { limits: record.limits, usage } as OrchestrationBudgetSnapshot; + const costStatus=budgetCostStatus(record); + const costIncomplete=costStatus.incomplete; + const unknownCostLimit=record.limits.costUsd!==null && costIncomplete; + const provisionalCost=costStatus.provisional; + if(costIncomplete || provisionalCost)remaining.costUsd=null; + const overLimit=isOver(thresholdView); + const over = overLimit || unknownCostLimit; + const atWarning = isAtWarning(thresholdView); + let pauseStateChanged=false; + if(record.paused && record.pauseCause==="cost-data" && overLimit){record.pauseCause="limit";pauseStateChanged=true;} + if(record.paused && record.pauseCause==="cost-data" && !overLimit && !unknownCostLimit && !record.enforcementFailure){ + record.paused=false;delete record.pauseCause;pauseStateChanged=true; + } + const pauseNow = !record.paused && over; + const warnNow = !record.warned && atWarning; + if (pauseNow) {record.paused = true;record.pauseCause=overLimit ? "limit" : "cost-data";pauseStateChanged=true;} + if (warnNow) record.warned = true; + const paused = record.paused || over; + const warning = record.warned || atWarning; + const reason = overReasons(record.limits, usage); + const snapshot: OrchestrationBudgetSnapshot = { + rootSessionId, + limits: { ...record.limits }, + usage, + remaining, + warning, + paused, + ...(paused ? { reason: unknownCostLimit + ? "Cost budget cannot be enforced because some agent costs are unavailable. Task paused; configure prices or remove the cost limit." + : reason ? `Budget reached: ${reason}. New subagents and agent input are paused.` + : "This budget pause remains active. Change or clear the task limits to resume." } : {}), + data: { + tokens: usage.tokens === null ? "none" : "available", + costUsd: usage.costUsd === null ? "none" : costIncomplete || provisionalCost ? "partial" : "available", + duration: usage.durationMs === null ? "none" : "available" + } + }; + if (pauseStateChanged || warnNow) { + this.persistQuietly(); + if (warnNow) try { this.callbacks.onWarning?.(snapshot); } catch { /* an app notification cannot affect the budget */ } + if (pauseNow) try { this.callbacks.onPause?.(snapshot); } catch { /* a UI callback cannot undo a hard limit */ } + } + if(record.enforcementFailure && !snapshot.reason?.includes(record.enforcementFailure))snapshot.reason=`${snapshot.reason ?? "Task budget is paused."} ${record.enforcementFailure}`; + this.scheduleDeadline(rootSessionId,record); + return snapshot; + } + + private scheduleDeadline(id:string,record:StoredBudget):void { + const existing=this.deadlines.get(id); + if(existing)clearTimeout(existing); + this.deadlines.delete(id); + if(record.paused || record.limits.durationMs===null || record.startedAt===undefined)return; + const due=record.startedAt+record.limits.durationMs*(record.warned ? 1 : 0.8); + const timer=setTimeout(()=>{this.deadlines.delete(id);this.emit(this.snapshot(id,record.startedAt));},Math.max(1,Math.min(2_147_483_647,due-Date.now()))); + timer.unref();this.deadlines.set(id,timer); + } + + isPaused(rootSessionId: string, rootStartedAt?: number): boolean { + return this.snapshot(rootSessionId, rootStartedAt).paused; + } + + private record(rootSessionId: string): StoredBudget { + if (!this.state.budgets[rootSessionId]) { + if (Object.keys(this.state.budgets).length >= MAX_ROOTS) throw new Error("Too many orchestration budget records."); + this.state.budgets[rootSessionId] = emptyBudget(); + } + return this.state.budgets[rootSessionId]!; + } + + private async persist(): Promise { + const snapshot = JSON.stringify(this.state); + if(Buffer.byteLength(snapshot)>1024*1024)throw new Error("Task budget store exceeds 1 MB."); + const operation = this.writeQueue.catch(() => undefined).then(async () => { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + const temporary = `${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, this.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + }); + this.writeQueue = operation; + await operation; + } + + private persistQuietly(): void { + void this.persist().catch(() => undefined); + } + + private emit(snapshot: OrchestrationBudgetSnapshot): void { + try { this.callbacks.onChange?.(snapshot); } catch { /* events do not affect accounting */ } + for (const listener of this.listeners) try { listener(snapshot); } catch { /* one UI listener cannot affect another */ } + } + + private assertLoaded(): void { if (!this.loaded) throw new Error("Orchestration budget service must be loaded before use."); } +} + +function emptyBudget(): StoredBudget { return { limits: { ...EMPTY_LIMITS }, tokens: null, costUsd: null, reportedAt: null, warned: false, paused: false }; } +function normalizeLimits(value: OrchestrationBudgetLimits): OrchestrationBudgetLimits { + if (!value || typeof value !== "object") throw new Error("Budget limits are invalid."); + return { tokens: normalizeLimit(value.tokens, "tokens"), costUsd: normalizeLimit(value.costUsd, "costUsd"), durationMs: normalizeLimit(value.durationMs, "durationMs") }; +} +function normalizeLimit(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${name} limit must be a positive finite number or null.`); + return value; +} +function normalizeUsage(value: unknown, name: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${name} usage must be a nonnegative finite number or null.`); + return value; +} +function difference(limit: number | null, usage: number | null): number | null { return limit === null || usage === null ? null : Math.max(0, limit - usage); } +function reached(limit:number|null,usage:number|null,threshold=1):boolean { + return limit!==null && usage!==null && usage>=limit*threshold; +} +function isOver(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens) || reached(limits.costUsd,usage.costUsd) || reached(limits.durationMs,usage.durationMs); +} +function isAtWarning(value: Pick): boolean { + const {limits,usage}=value; + return reached(limits.tokens,usage.tokens,0.8) || reached(limits.costUsd,usage.costUsd,0.8) || reached(limits.durationMs,usage.durationMs,0.8); +} +function overReasons(limits: OrchestrationBudgetLimits, usage: OrchestrationBudgetUsage): string { + const reasons:string[]=[]; + if(reached(limits.tokens,usage.tokens))reasons.push("token limit"); + if(reached(limits.costUsd,usage.costUsd))reasons.push("cost limit"); + if(reached(limits.durationMs,usage.durationMs))reasons.push("time limit"); + return reasons.join(", "); +} +function requireId(value: unknown): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error("Orchestration root session id is invalid."); } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 200) : "file is unavailable"; } +function budgetCostStatus(record:StoredBudget):BudgetCostStatus { + let count=0,allProvisional=true,provisional=false,unknownObservedCost=false; + for(const session of Object.values(record.sessions ?? {})) { + count++; + if(session.provisional===true)provisional=true; + else {allProvisional=false;if(session.costUsd===null)unknownObservedCost=true;} + } + const onlyProvisionalLedger=record.usageSource==="session-ledger" && count>0 && allProvisional; + return { + incomplete:!onlyProvisionalLedger && record.reportedAt!==null && (record.costUsd===null || unknownObservedCost), + provisional + }; +} +function isStore(value: unknown): value is StoreState { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if (record.version !== 1 || !record.budgets || typeof record.budgets !== "object" || Array.isArray(record.budgets)) return false; + const budgets = record.budgets as Record; + return Object.keys(budgets).length <= MAX_ROOTS && Object.values(budgets).every(isBudgetRecord); +} +function nonnegativeOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>=0; +} +function positiveOrNull(value:unknown):boolean { + return value===null || typeof value==="number" && Number.isFinite(value) && value>0; +} +function isUsageLedger(value:unknown):boolean { + if(!value || typeof value!=="object" || Array.isArray(value) || Object.keys(value).length>2048)return false; + return Object.values(value).every(row=>row && typeof row==="object" && nonnegativeOrNull(row.tokens) && nonnegativeOrNull(row.costUsd) + && (row.provisional===undefined || typeof row.provisional==="boolean")); +} +function isBudgetRecord(candidate:unknown):boolean { + if(!candidate || typeof candidate!=="object" || Array.isArray(candidate))return false; + const item=candidate as Record; + if(!item.limits || typeof item.limits!=="object" || Array.isArray(item.limits))return false; + if(item.sessions!==undefined && !isUsageLedger(item.sessions))return false; + if(item.startedAt!==undefined && !(typeof item.startedAt==="number" && Number.isFinite(item.startedAt) && item.startedAt>0))return false; + const limits=item.limits as Record; + return positiveOrNull(limits.tokens) && positiveOrNull(limits.costUsd) && positiveOrNull(limits.durationMs) + && nonnegativeOrNull(item.tokens) && nonnegativeOrNull(item.costUsd) + && (item.reportedAt===null || typeof item.reportedAt==="number" && Number.isFinite(item.reportedAt)) + && (item.usageSource===undefined || item.usageSource==="direct" || item.usageSource==="session-ledger") + && (item.pauseCause===undefined || item.pauseCause==="limit" || item.pauseCause==="cost-data") + && (item.enforcementFailure===undefined || typeof item.enforcementFailure==="string" && item.enforcementFailure.length<=500) + && typeof item.warned==="boolean" && typeof item.paused==="boolean"; +} diff --git a/src/main/services/OrchestrationTaskBoard.ts b/src/main/services/OrchestrationTaskBoard.ts new file mode 100644 index 000000000..7dc904c9a --- /dev/null +++ b/src/main/services/OrchestrationTaskBoard.ts @@ -0,0 +1,376 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; + +export type OrchestrationTaskStatus = "open" | "claimed" | "done" | "closed"; + +export interface OrchestrationTask { + id: string; + rootSessionId: string; + title: string; + description: string; + progress: string; + ownerSessionId: string | null; + ownerName: string | null; + status: OrchestrationTaskStatus; + dependencies: string[]; + result: string | null; + createdBySessionId: string; + createdAt: number; + updatedAt: number; +} + +export interface OrchestrationTaskBoardChange { + projectRoot: string; + rootSessionId: string; + tasks: OrchestrationTask[]; + revision: number; +} + +export interface NewOrchestrationTask { + title: string; + description?: string; + dependencies?: string[]; + ownerSessionId?: string | null; + ownerName?: string | null; +} + +export interface OrchestrationTaskPatch { + title?: string; + description?: string; + progress?: string; + status?: "open" | "claimed"; + ownerSessionId?: string | null; + ownerName?: string | null; + dependencies?: string[]; +} + +interface BoardState { + version: 1; + revision: number; + tasks: OrchestrationTask[]; +} + +const EMPTY_BOARD: BoardState = { version: 1, revision: 0, tasks: [] }; +const MAX_TASKS = 512; +const MAX_TITLE = 240; +const MAX_DESCRIPTION = 8_000; +const MAX_PROGRESS = 4_000; +const MAX_RESULT = 8_000; +const MAX_DEPENDENCIES = 64; +const locks = new Map>(); + +/** + * Atomic, project-keyed task storage outside the project tree. Agent shell tools can edit project files but cannot + * bypass these ownership checks by editing the board file. One root session owns one task group on the project board. + */ +export class OrchestrationTaskBoard { + private readonly storageRoot: string; + private readonly listeners = new Set<(change: OrchestrationTaskBoardChange) => void>(); + + constructor(storageRoot: string) { + this.storageRoot = resolve(storageRoot); + } + + subscribe(listener: (change: OrchestrationTaskBoardChange) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** Agent-facing list is always scoped to one root orchestrator. */ + async listTasks(projectRoot: string, rootSessionId: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + requireId(rootSessionId, "rootSessionId"); + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) }; + } + + /** Human-facing project view groups tasks by orchestrator; it is not exposed through agent tools. */ + async listProjectTasks(projectRoot: string): Promise<{ revision: number; tasks: OrchestrationTask[] }> { + const state = await this.read(this.boardLocation(projectRoot).path); + return { revision: state.revision, tasks: state.tasks.map(copyTask) }; + } + + async addTask(projectRoot: string, rootSessionId: string, createdBySessionId: string, input: NewOrchestrationTask): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(createdBySessionId, "createdBySessionId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + ensureCapacity(state); + const dependencies = validateDependencies(input.dependencies ?? []); + ensureDependenciesExist(state, rootSessionId, dependencies); + const now = Date.now(); + const next: OrchestrationTask = { + id: randomUUID(), rootSessionId, title: requiredText(input.title, "title", MAX_TITLE), + description: optionalText(input.description, "description", MAX_DESCRIPTION) ?? "", + progress: "", ownerSessionId: optionalId(input.ownerSessionId), ownerName: optionalName(input.ownerName), + status: input.ownerSessionId ? "claimed" : "open", dependencies, result: null, + createdBySessionId, createdAt: now, updatedAt: now + }; + state.tasks.push(next); + return copyTask(next); + }); + } + + /** Atomic claim: competing callers serialize through the store lock; the loser gets the current owner. */ + async claimTask(projectRoot: string, rootSessionId: string, actorSessionId: string, actorName: string, taskId: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "claimed" && task.ownerSessionId !== actorSessionId) { + const owner = task.ownerName || task.ownerSessionId || "another agent"; + throw new Error(`Task ${task.id} is already claimed by ${owner}.`); + } + if (task.status !== "open" && !(task.status === "claimed" && task.ownerSessionId === actorSessionId)) { + throw new Error(`Task ${task.id} is ${task.status} and cannot be claimed.`); + } + requireDependenciesDone(state, rootSessionId, task); + task.ownerSessionId = actorSessionId; + task.ownerName = optionalName(actorName) ?? actorSessionId; + task.status = "claimed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Agent edits are limited to the task it owns; only the root orchestrator may reassign or alter dependencies. */ + async updateTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch): Promise { + return this.editTask(projectRoot, rootSessionId, actorSessionId, taskId, patch, false); + } + + private async editTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, patch: OrchestrationTaskPatch, person: boolean): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + if (!isRecord(patch) || Object.keys(patch).length === 0) throw new Error("At least one task field is required."); + const allowed = new Set(["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]); + const extra = Object.keys(patch).find((key) => !allowed.has(key)); + if (extra) throw new Error(`Unknown task field "${extra}".`); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (!person && (task.status === "closed" || task.status === "done")) { + throw new Error(`Task ${task.id} is ${task.status}; only the person may reopen or change it.`); + } + const isRoot = actorSessionId === rootSessionId; + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (!isRoot && (patch.ownerSessionId !== undefined || patch.ownerName !== undefined || patch.dependencies !== undefined)) { + throw new Error("Only the orchestrator or person may reassign a task or change its dependencies."); + } + if (patch.title !== undefined) task.title = requiredText(patch.title, "title", MAX_TITLE); + if (patch.description !== undefined) task.description = requiredText(patch.description, "description", MAX_DESCRIPTION); + if (patch.progress !== undefined) task.progress = requiredText(patch.progress, "progress", MAX_PROGRESS); + if (patch.dependencies !== undefined) { + task.dependencies = validateDependencies(patch.dependencies); + if (task.dependencies.includes(task.id)) throw new Error("A task cannot depend on itself."); + ensureDependenciesExist(state, rootSessionId, task.dependencies); + assertAcyclic(state.tasks.filter((item) => item.rootSessionId === rootSessionId), task.id); + } + if (patch.ownerSessionId !== undefined) { + task.ownerSessionId = optionalId(patch.ownerSessionId); + if (task.ownerSessionId === null) task.ownerName = null; + else if (patch.ownerName !== undefined) task.ownerName = optionalName(patch.ownerName) ?? task.ownerSessionId; + task.status = task.ownerSessionId ? "claimed" : "open"; + } else if (patch.ownerName !== undefined && isRoot) { + task.ownerName = optionalName(patch.ownerName); + } + if (patch.status !== undefined) { + if (patch.status === "claimed" && !task.ownerSessionId) throw new Error("A task must have an owner before it can be claimed."); + if (patch.status === "open") { + if (!isRoot && task.ownerSessionId !== actorSessionId) throw new Error("Only the task owner or orchestrator can release this task."); + task.ownerSessionId = null; + task.ownerName = null; + } + task.status = patch.status as "open" | "claimed"; + } + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async completeTask(projectRoot: string, rootSessionId: string, actorSessionId: string, taskId: string, result: string): Promise { + requireId(rootSessionId, "rootSessionId"); + requireId(actorSessionId, "actorSessionId"); + requireId(taskId, "taskId"); + const text = requiredText(result, "result", MAX_RESULT); + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (actorSessionId !== rootSessionId && task.ownerSessionId !== actorSessionId) throw new Error(`Task ${task.id} belongs to another agent.`); + if (task.status === "done" || task.status === "closed") throw new Error(`Task ${task.id} is already ${task.status}.`); + requireDependenciesDone(state, rootSessionId, task); + task.status = "done"; + task.result = text; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Person/UI operations use explicit methods that bypass agent ownership while retaining the same atomic store. */ + async assignTask(projectRoot: string, rootSessionId: string, taskId: string, ownerSessionId: string | null, ownerName?: string | null): Promise { + return this.editTask(projectRoot, rootSessionId, rootSessionId, taskId, { ownerSessionId, ...(ownerName !== undefined ? { ownerName } : {}) }, true); + } + + async closeTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + task.status = "closed"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + async reopenTask(projectRoot: string, rootSessionId: string, taskId: string): Promise { + return this.mutate(projectRoot, rootSessionId, (state) => { + const task = findTask(state, rootSessionId, taskId); + if (task.status === "done") throw new Error("A completed task cannot be reopened without being edited first."); + task.status = task.ownerSessionId ? "claimed" : "open"; + task.updatedAt = Date.now(); + return copyTask(task); + }); + } + + /** Host-only workspace import. All ids are remapped, and a malformed graph writes nothing. */ + async importGroup(projectRoot:string,rootSessionId:string,rows:unknown,sessionIds:Record):Promise { + requireId(rootSessionId,"rootSessionId"); + if(!Array.isArray(rows) || rows.length>MAX_TASKS || !rows.every(isOrchestrationTask))throw new Error("Invalid workspace task board."); + const ids=new Map(rows.map(row=>[row.id,randomUUID()])); + if(ids.size!==rows.length)throw new Error("Workspace tasks contain duplicate ids."); + const imported:OrchestrationTask[]=rows.map(row=>({ + id:ids.get(row.id)!,rootSessionId,title:requiredText(row.title,"title",MAX_TITLE), + description:optionalText(row.description,"description",MAX_DESCRIPTION) ?? "", + progress:optionalText(row.progress,"progress",MAX_PROGRESS) ?? "", + ownerSessionId:row.ownerSessionId ? sessionIds[row.ownerSessionId] ?? null : null, + ownerName:row.ownerSessionId && sessionIds[row.ownerSessionId] ? optionalText(row.ownerName,"ownerName",200) ?? null : null, + status:row.status==="claimed" && (!row.ownerSessionId || !sessionIds[row.ownerSessionId]) ? "open" : row.status, + dependencies:validateDependencies(row.dependencies).map(id=>{const mapped=ids.get(id);if(!mapped)throw new Error("Workspace task has a missing dependency.");return mapped;}), + result:optionalText(row.result,"result",MAX_RESULT) ?? null, + createdBySessionId:sessionIds[row.createdBySessionId] ?? rootSessionId,createdAt:row.createdAt,updatedAt:row.updatedAt + })); + assertAcyclic(imported); + await this.mutate(projectRoot,rootSessionId,state=>{if(state.tasks.length+imported.length>MAX_TASKS)throw new Error("Workspace task board exceeds capacity.");state.tasks.push(...imported);}); + } + + private async read(path: string): Promise { + try { + const text = await readFile(path, "utf8"); + if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + const candidate: unknown = JSON.parse(text); + if (!isBoardState(candidate)) throw new Error("Task board has an invalid format."); + return candidate; + } catch (error) { + if (isMissing(error)) return structuredClone(EMPTY_BOARD); + throw new Error(`Could not read orchestration task board: ${errorText(error)}`); + } + } + + private async mutate(projectRoot: string, rootSessionId: string, mutation: (state: BoardState) => T): Promise { + const location = this.boardLocation(projectRoot); + return withLock(location.path, async () => { + const state = await this.read(location.path); + const result = mutation(state); + state.revision += 1; + const payload = JSON.stringify(state); + if (Buffer.byteLength(payload, "utf8") > 2 * 1024 * 1024) throw new Error("Task board exceeds 2 MB."); + await mkdir(dirname(location.path), { recursive: true, mode: 0o700 }); + const temporary = `${location.path}.${randomUUID()}.tmp`; + await writeFile(temporary, payload, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, location.path); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + const change: OrchestrationTaskBoardChange = { + projectRoot: location.normalizedRoot, + rootSessionId, + revision: state.revision, + tasks: state.tasks.filter((task) => task.rootSessionId === rootSessionId).map(copyTask) + }; + for (const listener of this.listeners) { + try { listener(change); } catch { /* one renderer listener cannot prevent persistence or another listener */ } + } + return result; + }); + } + + private boardLocation(projectRoot: string): {path:string;normalizedRoot:string} { + const normalizedRoot=resolve(projectRoot); + const digest=createHash("sha256").update(normalizedRoot).digest("hex").slice(0,40); + return {path:join(this.storageRoot,"orchestration-tasks",`${digest}.json`),normalizedRoot}; + } +} + +function withLock(key: string, run: () => Promise): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + let release!: () => void; + const next = new Promise((resolveLock) => { release = resolveLock; }); + locks.set(key, next); + return previous.catch(() => undefined).then(run).finally(() => { + release(); + if (locks.get(key) === next) locks.delete(key); + }); +} + +function ensureCapacity(state: BoardState): void { if (state.tasks.length >= MAX_TASKS) throw new Error(`The project task board holds at most ${MAX_TASKS} tasks.`); } +function findTask(state: BoardState, rootSessionId: string, taskId: string): OrchestrationTask { + const task = state.tasks.find((item) => item.rootSessionId === rootSessionId && item.id === taskId); + if (!task) throw new Error("Task does not exist in this orchestrator's task group."); + return task; +} +function ensureDependenciesExist(state: BoardState, rootSessionId: string, dependencies: string[]): void { + for (const id of dependencies) findTask(state, rootSessionId, id); +} +function requireDependenciesDone(state: BoardState, rootSessionId: string, task: OrchestrationTask): void { + const waiting = task.dependencies.map((id) => findTask(state, rootSessionId, id)).filter((dependency) => dependency.status !== "done"); + if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for dependencies: ${waiting.map((item) => item.title).join(", ")}.`); +} +function assertAcyclic(tasks: readonly OrchestrationTask[], startAt?: string): void { + const byId = new Map(tasks.map((task) => [task.id, task])), active = new Set(), checked = new Set(); + const visit = (id: string): void => { + if (active.has(id)) throw new Error("Task dependencies cannot form a cycle."); + if (checked.has(id)) return; + active.add(id); + for (const dependency of byId.get(id)?.dependencies ?? []) visit(dependency); + active.delete(id); + checked.add(id); + }; + for (const id of startAt ? [startAt] : tasks.map((task) => task.id)) visit(id); +} +function validateDependencies(value: unknown): string[] { + if (!Array.isArray(value) || value.length > MAX_DEPENDENCIES || value.some((item) => typeof item !== "string" || item.length > 80)) { + throw new Error(`dependencies must be an array of at most ${MAX_DEPENDENCIES} task ids.`); + } + const result = [...new Set(value as string[])]; + if (result.length !== value.length) throw new Error("dependencies cannot contain duplicates."); + return result; +} +function requiredText(value: unknown, name: string, max: number): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > max) throw new Error(`${name} must contain 1 to ${max} characters.`); + return value.trim(); +} +function optionalText(value: unknown, name: string, max: number): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string" || value.length > max) throw new Error(`${name} must be at most ${max} characters.`); + return value.trim(); +} +function requireId(value: unknown, name: string): asserts value is string { if (typeof value !== "string" || value.length < 1 || value.length > 160) throw new Error(`${name} is invalid.`); } +function optionalId(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; requireId(value, "ownerSessionId"); return value; } +function optionalName(value: unknown): string | null { if (value === undefined || value === null || value === "") return null; if (typeof value !== "string" || value.length > 160) throw new Error("ownerName is invalid."); return value.trim(); } +function copyTask(task: OrchestrationTask): OrchestrationTask { return { ...task, dependencies: [...task.dependencies] }; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isBoardState(value: unknown): value is BoardState { + return isRecord(value) && value.version === 1 && Number.isInteger(value.revision) && Array.isArray(value.tasks) + && value.tasks.length <= MAX_TASKS && value.tasks.every(isOrchestrationTask); +} +function isOrchestrationTask(value: unknown): value is OrchestrationTask { + if (!isRecord(value)) return false; + return typeof value.id === "string" && typeof value.rootSessionId === "string" && typeof value.title === "string" + && typeof value.description === "string" && typeof value.progress === "string" + && (value.ownerSessionId === null || typeof value.ownerSessionId === "string") + && (value.ownerName === null || typeof value.ownerName === "string") + && ["open", "claimed", "done", "closed"].includes(value.status as string) + && Array.isArray(value.dependencies) && value.dependencies.every((dependency) => typeof dependency === "string") + && (value.result === null || typeof value.result === "string") && typeof value.createdBySessionId === "string" + && Number.isFinite(value.createdAt) && Number.isFinite(value.updatedAt); +} +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function errorText(error: unknown): string { return error instanceof Error ? error.message.slice(0, 240) : "file is unavailable"; } diff --git a/src/main/services/OrchestrationTemplateService.ts b/src/main/services/OrchestrationTemplateService.ts new file mode 100644 index 000000000..82c4637c3 --- /dev/null +++ b/src/main/services/OrchestrationTemplateService.ts @@ -0,0 +1,360 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; +import { lazyRequire } from "../lazyRequire.ts"; +const yaml = lazyRequire("yaml"); +import type { ReasoningEffort } from "../../shared/launchModel.ts"; + +const FLOW_DIRECTORY = join(".canvastty", "flows"); +const MAX_FLOW_BYTES = 64 * 1024; +const MAX_FLOW_FILES = 128; +const MAX_ROLES = 16; +const MAX_TEXT = 8_000; +const ALLOWED_KEYS = new Set(["id", "name", "description", "roles", "finalStep"]); +const ROLE_KEYS = new Set(["id", "title", "instruction", "count", "model", "effort"]); +const EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]); + +export interface OrchestrationTemplateRole { + id: string; + title: string; + instruction: string; + count: number; + model?: string; + effort?: ReasoningEffort; +} + +/** A flow can choose role names, prompts, a model and reasoning effort; it cannot change the caller's permissions. */ +export interface OrchestrationTemplate { + id: string; + name: string; + description: string; + roles: OrchestrationTemplateRole[]; + finalStep: string; + expectedSubagents: number; + builtIn: boolean; + source?: string; + trusted?: boolean; + digest?: string; +} + +export interface OrchestrationTemplateIssue { + file: string; + line: number; + message: string; +} + +export interface OrchestrationTemplateList { + templates: OrchestrationTemplate[]; + errors: OrchestrationTemplateIssue[]; +} + +const BUILT_IN_FLOWS: readonly Omit[] = [ + { + id: "split-parallel-synthesize", + name: "Split, work in parallel, synthesize", + description: "Break a task into independent pieces, run the pieces concurrently, then combine the results.", + roles: [ + { id: "planner", title: "Plan the split", instruction: "Break the requested work into independent, bounded parts. Do not edit files. Return the parts and dependencies.", count: 1 }, + { id: "worker", title: "Complete one part", instruction: "Complete only the assigned part. State changed files and the result clearly.", count: 3 } + ], + finalStep: "Review every result, resolve conflicts, and present one concise combined outcome." + }, + { + id: "executor-reviewer", + name: "Executor and reviewer", + description: "One agent implements; a second independently reviews the diff and reports concrete issues.", + roles: [ + { id: "executor", title: "Implement", instruction: "Implement the requested change and report the files changed and verification performed.", count: 1 }, + { id: "reviewer", title: "Review", instruction: "Read the implementation diff and report only actionable bugs, regressions, and missing checks. Do not modify files.", count: 1, effort: "high" } + ], + finalStep: "Consider the review findings, ask the executor to fix valid issues, then report the final change and remaining risks." + }, + { + id: "three-options-judge", + name: "Three options and a judge", + description: "Explore three independent solutions and compare them against the request before choosing.", + roles: [ + { id: "option", title: "Propose one solution", instruction: "Develop one distinct solution. Explain tradeoffs and how it meets the request.", count: 3 }, + { id: "judge", title: "Compare the options", instruction: "Compare the three proposals against the user's constraints. Choose one and explain why.", count: 1, effort: "high" } + ], + finalStep: "Present the selected option, its reason, and the main tradeoff. Do not implement without the user's request." + }, + { + id: "find-and-verify-bugs", + name: "Find bugs and verify findings", + description: "Search independently for defects, then have another agent validate each candidate against the source.", + roles: [ + { id: "finder", title: "Find candidate bugs", instruction: "Inspect the assigned area and return specific candidate bugs with file and line evidence. Do not edit files.", count: 2 }, + { id: "verifier", title: "Verify candidate bugs", instruction: "Check each candidate against source and call paths. Reject false positives and provide evidence for validated bugs.", count: 1, effort: "high" } + ], + finalStep: "Report only independently verified findings, with impact and source evidence." + } +]; + +/** Loads project flows on every call so a new file is visible without restarting CanvasTTY. */ +export class OrchestrationTemplateService { + private readonly approvedTemplates = new WeakSet(); + private approvalQueue: Promise = Promise.resolve(); + private readonly approvalPath?: string; + constructor(approvalPath?: string) { this.approvalPath = approvalPath; } + + async list(projectRoot: string): Promise { + const templates: OrchestrationTemplate[] = BUILT_IN_FLOWS.map((flow) => ({ + ...structuredClone(flow), + expectedSubagents: flow.roles.reduce((sum, role) => sum + role.count, 0), + builtIn: true, trusted: true + })); + for (const template of templates) this.approvedTemplates.add(template); + const errors: OrchestrationTemplateIssue[] = []; + const root = await realpath(projectRoot); + let approvals = new Map(); + try { approvals = await this.readApprovals(); } + catch { errors.push({ file: "flow approvals", line: 0, message: "Flow approvals could not be read. Project flows require approval again before use." }); } + let flowDirectory: string | null; + try { flowDirectory = await projectFlowDirectory(root, false); } + catch { return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory must stay inside the project." }] }; } + if (!flowDirectory) return { templates, errors }; + let files: string[]; + try { + files = (await readdir(flowDirectory)).filter((file) => /\.(?:yaml|yml)$/iu.test(file)).sort().slice(0, MAX_FLOW_FILES); + } catch (error) { + if (isMissing(error)) return { templates, errors }; + return { templates, errors: [{ file: FLOW_DIRECTORY, line: 1, message: "The flow directory could not be read." }] }; + } + for (const file of files) { + const display = `${FLOW_DIRECTORY}/${file}`; + try { + const path = join(flowDirectory, file); + const [actual, info] = await Promise.all([realpath(path), stat(path)]); + if (!info.isFile()) { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + try { assertInsideProject(flowDirectory, actual); } + catch { + errors.push({ file: display, line: 1, message: "Flow files must be regular files inside .canvastty/flows." }); + continue; + } + if (info.size > MAX_FLOW_BYTES) { + errors.push({ file: display, line: 1, message: `Flow file exceeds ${MAX_FLOW_BYTES} bytes.` }); + continue; + } + const source = await readFile(actual, "utf8"); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error("Flow changed beyond its size limit."); + const parsed = parseFlow(source, display); + if ("issue" in parsed) errors.push(parsed.issue); + else if (templates.some((item) => item.id === parsed.template.id)) { + errors.push({ file: display, line: 1, message: `Flow id "${parsed.template.id}" is already in use.` }); + } else { + const digest = createHash("sha256").update(source).digest("hex"); + const trusted = approvals.get(JSON.stringify([root, parsed.template.id])) === digest; + const template = { ...parsed.template, digest, trusted }; + templates.push(template); + if (trusted) this.approvedTemplates.add(template); + } + } catch { + errors.push({ file: display, line: 1, message: "Flow file could not be read." }); + } + } + return { templates, errors }; + } + + /** Writes a validated project flow with an atomic replace. Built-in ids cannot be shadowed. */ + async save(projectRoot: string, flow: Omit): Promise { + const source = yaml().stringify(flow); + if (Buffer.byteLength(source, "utf8") > MAX_FLOW_BYTES) throw new Error(`Flow file exceeds ${MAX_FLOW_BYTES} bytes.`); + const parsed = parseFlow(source, "flow.yaml"); + if ("issue" in parsed) throw new Error(formatIssue(parsed.issue)); + if (BUILT_IN_FLOWS.some((builtIn) => builtIn.id === parsed.template.id)) throw new Error("A built-in flow cannot be overwritten."); + const directory = await projectFlowDirectory(await realpath(projectRoot), true); + const safeId = parsed.template.id.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, ""); + if (!safeId) throw new Error("Flow id must include a letter or number."); + const target = join(directory, `${safeId}.yaml`); + const temporary = join(directory, `.${safeId}.${randomUUID()}.tmp`); + await writeFile(temporary, source, { encoding: "utf8", mode: 0o600, flag: "wx" }); + try { await rename(temporary, target); } + catch (error) { + const { unlink } = await import("node:fs/promises"); + await unlink(temporary).catch(() => undefined); + throw error; + } + return target; + } + + /** The string a launcher can append to the orchestrator's initial prompt. */ + instructions(template: OrchestrationTemplate): string { + if (!this.approvedTemplates.has(template)) throw new Error("This project flow requires the person's approval of its current instructions."); + return buildOrchestrationTemplateInstructions(template); + } + + /** Desktop-only preview: the agent tools never return unapproved prompt fields. */ + async preview(projectRoot: string, id: string): Promise<{ instructions: string; digest: string | null }> { + const template = (await this.list(projectRoot)).templates.find(row => row.id === id); + if (!template) throw new Error("Flow no longer exists."); + return { instructions: buildOrchestrationTemplateInstructions(template), digest: template.digest ?? null }; + } + + /** Bind approval to the exact bytes shown by the desktop, outside every agent-writable project. */ + approve(projectRoot: string, id: string, expectedDigest: string): Promise { + const work = this.approvalQueue.then(async () => { + if (!this.approvalPath) throw new Error("Flow approval storage is unavailable."); + const root = await realpath(projectRoot); + const template = (await this.list(root)).templates.find(row => row.id === id); + if (!template || template.builtIn || !/^[a-f0-9]{64}$/u.test(expectedDigest) || template.digest !== expectedDigest) + throw new Error("Flow changed since the preview. Review its current instructions before approval."); + const approvals = await this.readApprovals(), key = JSON.stringify([root, id]); + if (!approvals.has(key) && approvals.size >= 4096) throw new Error("Flow approval storage is full."); + approvals.set(key, expectedDigest); + await mkdir(dirname(this.approvalPath), { recursive: true, mode: 0o700 }); + const temporary = `${this.approvalPath}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify([...approvals]), { mode: 0o600, flag: "wx" }); + await rename(temporary, this.approvalPath); + } finally { await rm(temporary, { force: true }); } + }); + this.approvalQueue = work.catch(() => undefined); + return work; + } + + private async readApprovals(): Promise> { + if (!this.approvalPath) return new Map(); + let text: string; + try { text = await readFile(this.approvalPath, "utf8"); } + catch (error) { if (isMissing(error)) return new Map(); throw error; } + if (Buffer.byteLength(text) > 4 * 1024 * 1024) throw new Error("Flow approvals are too large."); + const entries: unknown = JSON.parse(text); + if (!Array.isArray(entries) || entries.length > 4096) throw new Error("Invalid flow approvals."); + const map = new Map(); + for (const row of entries) { + if (!Array.isArray(row) || row.length !== 2 || typeof row[0] !== "string" || typeof row[1] !== "string" || !/^[a-f0-9]{64}$/u.test(row[1])) + throw new Error("Invalid flow approval."); + const key: unknown = JSON.parse(row[0]); + if (!Array.isArray(key) || key.length !== 2 || typeof key[0] !== "string" || !isAbsolute(key[0]) || typeof key[1] !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(key[1])) + throw new Error("Invalid flow approval scope."); + map.set(row[0], row[1]); + } + return map; + } +} + +/** Resolve flow paths inside the canonical project root, creating missing components only for saves. */ +async function projectFlowDirectory(root: string, createIfMissing: true): Promise; +async function projectFlowDirectory(root: string, createIfMissing: false): Promise; +async function projectFlowDirectory(root: string, createIfMissing: boolean): Promise { + let directory = root; + for (const name of [".canvastty", "flows"]) { + const path = join(directory, name); + try { directory = await realpath(path); } + catch (error) { + if (!isMissing(error)) throw error; + if (!createIfMissing) return null; + await mkdir(path, { mode: 0o700 }); + directory = await realpath(path); + } + assertInsideProject(root, directory); + } + return directory; +} + +function assertInsideProject(root: string, path: string): void { + const rel = relative(root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error("Orchestration flow paths must stay inside the project."); + } +} + +export function buildOrchestrationTemplateInstructions(template: OrchestrationTemplate): string { + return [ + `Orchestration flow: ${template.name}`, + template.description, + `Expected subagents: ${template.expectedSubagents}.`, + "Follow the roles below in order; start independent roles in parallel when dependencies allow.", + ...template.roles.map((role) => { + const defaults = [role.model ? `model=${role.model}` : "", role.effort ? `effort=${role.effort}` : ""].filter(Boolean).join(", "); + return `- ${role.count} × ${role.title} (${role.id})${defaults ? `; suggested ${defaults}` : ""}: ${role.instruction}`; + }), + `Final step: ${template.finalStep}`, + "A flow only sets a work pattern and model defaults. Preserve the orchestration permissions, project-folder limits, launch profile ceiling, and budgets already enforced by CanvasTTY.", + "User task:", + "{{TASK}}" + ].join("\n"); +} + +function parseFlow(source: string, file: string): { template: OrchestrationTemplate } | { issue: OrchestrationTemplateIssue } { + let document; + const lines=new (yaml().LineCounter)(); + try { document = yaml().parseDocument(source, { uniqueKeys: true, prettyErrors: false, lineCounter:lines }); } + catch (error) { return { issue: { file, line: 1, message: `Invalid YAML: ${message(error)}` } }; } + if (document.errors.length > 0) { + const error = document.errors[0]!; + const line = error.pos ? lines.linePos(error.pos[0]).line : 1; + return { issue: { file, line, message: `Invalid YAML: ${cleanMessage(error.message)}` } }; + } + const value: unknown = document.toJS(); + const issue = validateFlow(value, file); + if (issue) { + const role=/^roles\[(\d+)\](?:\.([a-zA-Z]+))?/u.exec(issue.message); + const quoted=/unknown field "([^"]+)"/iu.exec(issue.message); + const field=/^(id|name|description|finalStep|roles)\b/u.exec(issue.message)?.[1]; + const unknown=/^Unknown flow field "([^"]+)"/u.exec(issue.message)?.[1]; + const path=role ? ["roles",Number(role[1]),...(role[2] || quoted?.[1] ? [role[2] ?? quoted![1]] : [])] : [unknown ?? field ?? "id"]; + const node=document.getIn(path,true) as {range?:[number,number,number]}|undefined; + if(node?.range)issue.line=lines.linePos(node.range[0]).line; + return { issue }; + } + const candidate = value as Record; + const roles = candidate.roles as OrchestrationTemplateRole[]; + const id = candidate.id as string; + return { + template: { + id, + name: candidate.name as string, + description: candidate.description as string, + roles, + finalStep: candidate.finalStep as string, + expectedSubagents: roles.reduce((sum, role) => sum + role.count, 0), + builtIn: false, + source: file + } + }; +} + +function validateFlow(value: unknown, file: string): OrchestrationTemplateIssue | null { + const fail = (message: string, line = 1): OrchestrationTemplateIssue => ({ file, line, message }); + if (!isRecord(value)) return fail("Flow must be a YAML object."); + const extra = Object.keys(value).find((key) => !ALLOWED_KEYS.has(key)); + if (extra) return fail(`Unknown flow field "${extra}"; flows cannot set launch permissions, profiles or folders.`); + if (typeof value.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,63}$/u.test(value.id)) return fail("id must be a lowercase slug of at most 64 characters."); + if (BUILT_IN_FLOWS.some((flow) => flow.id === value.id)) return fail("A project flow cannot replace a built-in flow."); + if (!nonEmpty(value.name, 120)) return fail("name must contain 1 to 120 characters."); + if (!nonEmpty(value.description, 500)) return fail("description must contain 1 to 500 characters."); + if (!nonEmpty(value.finalStep, MAX_TEXT)) return fail("finalStep must contain 1 to 8000 characters."); + if (!Array.isArray(value.roles) || value.roles.length < 1 || value.roles.length > MAX_ROLES) return fail(`roles must contain 1 to ${MAX_ROLES} entries.`); + let total = 0; + const ids = new Set(); + for (let index = 0; index < value.roles.length; index += 1) { + const role: unknown = value.roles[index]; + if (!isRecord(role)) return fail(`roles[${index}] must be an object.`); + const roleExtra = Object.keys(role).find((key) => !ROLE_KEYS.has(key)); + if (roleExtra) return fail(`roles[${index}] has unknown field "${roleExtra}"; role permissions are fixed by CanvasTTY.`); + if (typeof role.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,47}$/u.test(role.id) || ids.has(role.id)) return fail(`roles[${index}].id must be a unique lowercase slug.`); + ids.add(role.id); + if (!nonEmpty(role.title, 120)) return fail(`roles[${index}].title must contain 1 to 120 characters.`); + if (!nonEmpty(role.instruction, MAX_TEXT)) return fail(`roles[${index}].instruction must contain 1 to ${MAX_TEXT} characters.`); + if (!Number.isInteger(role.count) || (role.count as number) < 1 || (role.count as number) > 12) return fail(`roles[${index}].count must be an integer from 1 to 12.`); + if (role.model !== undefined && !nonEmpty(role.model, 160)) return fail(`roles[${index}].model must contain 1 to 160 characters.`); + if (role.effort !== undefined && !EFFORTS.has(role.effort as ReasoningEffort)) return fail(`roles[${index}].effort is not supported.`); + total += role.count as number; + } + if (total > 32) return fail("A flow may expect at most 32 subagents."); + return null; +} + +function formatIssue(issue: OrchestrationTemplateIssue): string { + return `${issue.file}:${issue.line}: ${issue.message}`; +} +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function nonEmpty(value: unknown, max: number): value is string { return typeof value === "string" && value.trim().length > 0 && value.length <= max; } +function isMissing(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } +function message(error: unknown): string { return cleanMessage(error instanceof Error ? error.message : "document could not be parsed"); } +function cleanMessage(value: string): string { return value.replace(/[\r\n\t]+/gu, " ").slice(0, 240); } diff --git a/src/main/services/OrchestrationUsageSync.ts b/src/main/services/OrchestrationUsageSync.ts new file mode 100644 index 000000000..0a8eec2eb --- /dev/null +++ b/src/main/services/OrchestrationUsageSync.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import type { UsagePrice } from "../../shared/backlog.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetSnapshot } from "./OrchestrationBudgetService.ts"; +import type { SessionTimelineService, TimelineTaskBudgetData, TimelineUsageCounter } from "./SessionTimelineService.ts"; + +interface CounterTotal {tokens:number;costUsd:number;costKnown:boolean;replaceCounterIds:Set} +export interface OrchestrationUsageScope {rootSessionId:string;rootStartedAt:number;memberSessionIds:readonly string[];budgetEnabled?:boolean} + +/** Synchronize the timeline's indexed task deltas plus host-owned launch placeholders into the budget ledger. */ +export function refreshOrchestrationUsage( + budget: OrchestrationBudgetService, + timeline: SessionTimelineService, + rootSessionId: string, + rootStartedAt: number, + memberSessionIds: readonly string[], + prices: UsagePrice[] +): OrchestrationBudgetSnapshot { + return refreshOrchestrationUsageBatch(budget,timeline,[{rootSessionId,rootStartedAt,memberSessionIds}],prices).get(rootSessionId)!; +} + +/** Gather every active root's compact usage rows in shared timeline passes before updating its ledger. */ +export function refreshOrchestrationUsageBatch( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scopes:readonly OrchestrationUsageScope[], + prices:UsagePrice[], + onSnapshot?:(scope:OrchestrationUsageScope,snapshot:OrchestrationBudgetSnapshot|undefined)=>void, + beforeRefresh?:(scope:OrchestrationUsageScope)=>void +):Map { + if(scopes.length===0)return new Map(); + const budgetScopes=scopes.filter(scope=>scope.budgetEnabled!==false); + if(budgetScopes.length===0) { + for(const scope of scopes) {beforeRefresh?.(scope);onSnapshot?.(scope,undefined);} + return new Map(); + } + const dataByTask=timeline.taskBudgetUsageByTask(budgetScopes.map(scope=>({taskId:scope.rootSessionId, + sessionIds:[scope.rootSessionId,...scope.memberSessionIds]})),prices); + const snapshots=new Map(); + for(const scope of scopes) { + beforeRefresh?.(scope); + const snapshot=scope.budgetEnabled===false ? undefined : refreshOneTask(budget,timeline,scope,dataByTask.get(scope.rootSessionId)!); + if(snapshot)snapshots.set(scope.rootSessionId,snapshot); + onSnapshot?.(scope,snapshot); + } + return snapshots; +} + +function refreshOneTask( + budget:OrchestrationBudgetService, + timeline:SessionTimelineService, + scope:OrchestrationUsageScope, + data:TimelineTaskBudgetData +):OrchestrationBudgetSnapshot { + const {rootSessionId,rootStartedAt}=scope; + const scopeSessions=data.sessionIds; + const totals=new Map(); + for(const contribution of data.contributions) { + let total=totals.get(contribution.identity); + if(!total){total={tokens:0,costUsd:0,costKnown:true,replaceCounterIds:new Set()};totals.set(contribution.identity,total);} + total.tokens+=contribution.tokens; + if(contribution.costUsd===null)total.costKnown=false;else total.costUsd+=contribution.costUsd; + } + + const sessionsWithUsage=new Set(); + const aliasesByIdentity=new Map>(); + for(const sessionId of scopeSessions)for(const usage of timeline.usageCounters(sessionId)) { + sessionsWithUsage.add(sessionId); + const identity=counterIdentity(sessionId,usage),aliases=aliasesByIdentity.get(identity) ?? new Set(); + aliases.add(hashCounter(sessionId,usage.id)); + aliases.add(hashCounter(sessionId,usage.legacyId ?? sessionId)); + aliasesByIdentity.set(identity,aliases); + } + for(const [identity,total] of totals)for(const alias of aliasesByIdentity.get(identity) ?? [])total.replaceCounterIds.add(alias); + + const pendingIds=[...sessionsWithUsage].map(sessionId=>hashCounter(sessionId,sessionId)); + let first=true; + for(const [identity,total] of totals) { + const replaceCounterIds=[...total.replaceCounterIds,...(first ? pendingIds : [])];first=false; + budget.recordSessionUsage(rootSessionId,hashIdentity(identity),{ + tokens:total.tokens, + costUsd:total.costKnown ? total.costUsd : null + },{rootStartedAt,replaceCounterIds}); + } + for(const sessionId of scopeSessions)if(!sessionsWithUsage.has(sessionId)) { + budget.recordSessionUsage(rootSessionId,hashCounter(sessionId,sessionId),{tokens:null,costUsd:null},{rootStartedAt,provisional:true}); + } + return budget.snapshot(rootSessionId,rootStartedAt); +} + +function counterIdentity(sessionId:string,usage:TimelineUsageCounter):string { + return usage.cumulative && usage.counterId!==undefined + ? JSON.stringify([usage.provider ?? "",usage.accountId ?? "",usage.source,usage.counterId]) + : JSON.stringify([sessionId,usage.source,usage.counterId ?? ""]); +} +function hashCounter(sessionId:string,counterId:string):string { + return createHash("sha256").update(`${sessionId}:${counterId}`).digest("hex"); +} +function hashIdentity(identity:string):string { + return createHash("sha256").update(`task-usage:${identity}`).digest("hex"); +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553ab..d873f71f0 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -1,3 +1,4 @@ +import { ACCOUNTS_PLUGIN_ID } from "./accountHomeIsolation.ts"; import { createHash, randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { @@ -597,6 +598,7 @@ export class PluginManager { pluginName: manifest.name, serviceId: service.id, launch: structuredClone(service.launch), + ...(plugin.manifest.id === ACCOUNTS_PLUGIN_ID ? { dataDir: join(this.dataRoot, plugin.manifest.id) } : {}), secrets: manifest.permissions.includes("secrets") }); } diff --git a/src/main/services/ProcessTreePause.ts b/src/main/services/ProcessTreePause.ts new file mode 100644 index 000000000..893cb5279 --- /dev/null +++ b/src/main/services/ProcessTreePause.ts @@ -0,0 +1,178 @@ +import { execFileSync } from "node:child_process"; + +export type ProcessGroupSignal = "SIGSTOP" | "SIGCONT"; +export interface OwnedProcessGroup { + readonly pid: number; +} +export interface ProcessTreePauseResult { + supported: boolean; + failed?: string; +} +export interface ProcessRow { pid: number; ppid: number } + +type SignalGroup = (groupId: number, signal: ProcessGroupSignal) => void; +/** + * Descendant tracking. `list` returns every process with its parent; `signal` delivers to one pid. A process that + * called setsid() (or otherwise left the PTY's process group) is still a descendant by parent chain, so it is found + * here even though the group signal cannot reach it. + */ +export interface ProcessTreeAccess { + list(): ProcessRow[]; + signal(pid: number, signal: ProcessGroupSignal): void; +} + +interface PausedTree { groupId: number; descendants: number[] } + +/** Bounded rescans: a running descendant may fork between one listing and its own SIGSTOP. */ +const MAX_SCAN_ROUNDS = 8; +const MAX_DESCENDANTS = 4096; + +/** + * Suspends a PTY process group whose leader was started and remains owned by this host, plus every process that + * descends from that leader by parent pid at pause time, including ones that moved to their own session. + * node-pty's forkpty child is the process-group/session leader, so a negative pid targets that child's group rather + * than a caller-selected pid. Callers still retain the precise PTY object; a PID alone is never accepted as a target. + * + * Known limit: a process whose parent already exited before the pause was re-parented to init (or a subreaper) and + * has no parent chain to the PTY any more, so it cannot be attributed to the task and keeps running. + */ +export class ProcessTreePause { + private readonly pausedGroups = new WeakMap(); + private readonly signalGroup: SignalGroup; + private readonly tree: ProcessTreeAccess | null; + readonly supported: boolean; + + /** + * With no arguments the real process table and signals are used. An injected `signalGroup` without `tree` + * disables descendant tracking, so a test double can never reach real processes through `ps`. + */ + constructor( + platform = process.platform, + signalGroup?: SignalGroup, + tree?: ProcessTreeAccess | null + ) { + this.supported = platform !== "win32"; + this.signalGroup = signalGroup ?? ((groupId, signal) => process.kill(-groupId, signal)); + this.tree = tree !== undefined ? tree : signalGroup ? null : (this.supported ? systemProcessTree() : null); + } + + isPaused(process: OwnedProcessGroup):boolean {return this.pausedGroups.has(process);} + + pause(process: OwnedProcessGroup): ProcessTreePauseResult { + if (!this.supported) return unsupportedResult(); + const groupId = process.pid; + if (!Number.isSafeInteger(groupId) || groupId <= 0) { + return { supported: true, failed: "The owned PTY did not provide a valid process-group identifier." }; + } + if (this.pausedGroups.has(process)) return { supported: true }; + try { + this.signalGroup(groupId, "SIGSTOP"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not suspend the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const scan = this.stopDescendants(groupId); + this.pausedGroups.set(process, { groupId, descendants: scan.stopped }); + return { supported: true, ...(scan.failed ? { failed: scan.failed } : {}) }; + } + + resume(process: OwnedProcessGroup): ProcessTreePauseResult { + // No group can be recorded on an unsupported platform, so cleanup is a no-op and must not + // manufacture a failure for every ordinary PTY exit. + if (!this.supported) return { supported: true }; + const paused = this.pausedGroups.get(process); + if (paused === undefined) return { supported: true }; + try { + this.signalGroup(paused.groupId, "SIGCONT"); + } catch (error) { + if (signalCode(error) !== "ESRCH") { + return { supported: true, failed: `Could not resume the owned PTY process group (${signalCode(error) ?? "unknown error"}).` }; + } + } + const remaining: number[] = []; + let failure: string | undefined; + for (const pid of paused.descendants) { + try { this.tree?.signal(pid, "SIGCONT"); } + catch (error) { + if (signalCode(error) === "ESRCH") continue; + remaining.push(pid); + failure = `Could not resume a detached descendant process (${signalCode(error) ?? "unknown error"}).`; + } + } + if (failure) { + // Keep ownership of the descendants that are still stopped so a later resume can retry them. + this.pausedGroups.set(process, { groupId: paused.groupId, descendants: remaining }); + return { supported: true, failed: failure }; + } + this.pausedGroups.delete(process); + return { supported: true }; + } + + /** Stops every descendant of `rootPid` by parent chain, rescanning until a pass finds nothing new. */ + private stopDescendants(rootPid: number): { stopped: number[]; failed?: string } { + if (!this.tree) return { stopped: [] }; + const stopped = new Set(), known = new Set([rootPid]); + const failures = new Set(); + for (let round = 0; round < MAX_SCAN_ROUNDS; round++) { + let rows: ProcessRow[]; + try { rows = this.tree.list(); } + catch { + failures.add("Could not list descendant processes; a process that left the PTY's process group may keep running."); + break; + } + const children = new Map(); + for (const row of rows) { + const list = children.get(row.ppid); + if (list) list.push(row.pid); else children.set(row.ppid, [row.pid]); + } + // Earlier-seen descendants stay roots, so a child whose parent exited between rounds is still reached + // when it was already known. + const queue = [...known]; + let found = false; + while (queue.length) { + const parent = queue.pop()!; + for (const pid of children.get(parent) ?? []) { + if (pid === process.pid || pid <= 1 || known.has(pid)) continue; + known.add(pid); + queue.push(pid); + if (known.size > MAX_DESCENDANTS) break; + try { this.tree.signal(pid, "SIGSTOP"); stopped.add(pid); found = true; } + catch (error) { + if (signalCode(error) !== "ESRCH") failures.add(`Could not suspend a descendant process (${signalCode(error) ?? "unknown error"}).`); + } + } + } + if (!found || known.size > MAX_DESCENDANTS) break; + } + if (known.size > MAX_DESCENDANTS) failures.add("The task has too many descendant processes to suspend individually."); + return { stopped: [...stopped], ...(failures.size ? { failed: [...failures].join(" ") } : {}) }; + } +} + +function systemProcessTree(): ProcessTreeAccess { + return { + list() { + const text = execFileSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }); + const rows: ProcessRow[] = []; + for (const line of text.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s*$/u.exec(line); + if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]) }); + } + return rows; + }, + signal(pid, signal) { process.kill(pid, signal); } + }; +} + +function signalCode(error: unknown): string | null { + if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code; + return null; +} + +function unsupportedResult(): ProcessTreePauseResult { + return { + supported: false, + failed: "Windows cannot safely suspend a PTY process tree; existing processes may continue while input and new launches are blocked." + }; +} diff --git a/src/main/services/ProviderUsageSource.ts b/src/main/services/ProviderUsageSource.ts new file mode 100644 index 000000000..6b1aac33a --- /dev/null +++ b/src/main/services/ProviderUsageSource.ts @@ -0,0 +1,201 @@ +import { readdir, realpath } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { Worker } from "node:worker_threads"; + +export interface ProviderUsage { + total: number; + input: number | null; + output: number | null; + model?: string; +} + +interface CachedUsage { signature: string; value: ProviderUsage } +interface WorkerUsageResult { usage?: ProviderUsage | null; signature?: string; unchanged?: boolean } + +const READ_TIMEOUT_MS = 2_000; +const MAX_ROLLOUT_BYTES = 1024 * 1024; +const MAX_ROLLOUT_LINE_CHARS = 256 * 1024; + +/** + * Read only published numeric counters from the local Codex index and rollout. + * Rollout JSON is parsed inside a bounded worker; message and transcript text + * never crosses the worker boundary. + */ +export class ProviderUsageSource { + private readonly home: string; + private readonly pending = new Map>(); + private readonly cache = new Map(); + + constructor(codexHome: string) { this.home = resolve(codexHome); } + + codexUsage(threadId: string): Promise { + if (!/^[\w-]{1,160}$/.test(threadId)) return Promise.resolve(null); + const current = this.pending.get(threadId); + if (current) return current; + + const cached = this.cache.get(threadId); + const read = this.read(threadId, cached?.signature).then((result) => { + if (result?.unchanged && cached) return cached.value; + if (!result?.usage || !result.signature) { + this.cache.delete(threadId); + return null; + } + this.cache.set(threadId, { signature: result.signature, value: result.usage }); + return result.usage; + }).catch(() => null).finally(() => this.pending.delete(threadId)); + this.pending.set(threadId, read); + return read; + } + + /** Compatibility for callers that only consume the CLI's aggregate count. */ + async codexTotal(threadId: string): Promise { + return (await this.codexUsage(threadId))?.total ?? null; + } + + private async read(threadId: string, previousSignature?: string): Promise { + const files = (await readdir(this.home)).filter((name) => /^state_\d+\.sqlite$/.test(name)) + .sort((a, b) => Number(b.match(/\d+/)![0]) - Number(a.match(/\d+/)![0])); + if (!files[0]) return null; + const database = join(this.home, files[0]); + const home = await realpath(this.home); + + return new Promise((resolveResult) => { + const worker = new Worker(CODEX_USAGE_WORKER, { + eval: true, + workerData: { database, home, threadId, previousSignature, maxRolloutBytes: MAX_ROLLOUT_BYTES, maxLineChars: MAX_ROLLOUT_LINE_CHARS } + }); + let finished = false; + const finish = (value: WorkerUsageResult | null): void => { + if (finished) return; + finished = true; + clearTimeout(timer); + void worker.terminate(); + resolveResult(value); + }; + const timer = setTimeout(() => finish(null), READ_TIMEOUT_MS); + worker.once("message", (value: unknown) => finish(isWorkerUsageResult(value) ? value : null)); + worker.once("error", () => finish(null)); + worker.once("exit", () => finish(null)); + }); + } +} + +/** Kept self-contained so every database and rollout operation stays off the main thread. */ +const CODEX_USAGE_WORKER = ` +(() => { + const { parentPort, workerData } = require("node:worker_threads"); + const { DatabaseSync } = require("node:sqlite"); + const fs = require("node:fs"); + const path = require("node:path"); + + function safeCount(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; + } + function safeModel(value) { + return typeof value === "string" && value.length > 0 && value.length <= 200 + && !/[\\u0000-\\u001f\\u007f]/u.test(value) ? value.trim() || null : null; + } + function pathInsideHome(file) { + return path.isAbsolute(file) ? path.resolve(file) : path.resolve(workerData.home, file); + } + function readLatestCounters(file) { + const descriptor = fs.openSync(file, "r"); + try { + const size = fs.fstatSync(descriptor).size; + const length = Math.min(size, workerData.maxRolloutBytes); + const buffer = Buffer.alloc(length); + if (length) fs.readSync(descriptor, buffer, 0, length, size - length); + const text = buffer.toString("utf8"); + const lines = text.split(String.fromCharCode(10)); + if (size > length) lines.shift(); // Do not parse a partial JSON record at the range boundary. + let model; + let usage = null; + for (const line of lines) { + if (line.length > workerData.maxLineChars || !line.includes("token_count") && !line.includes("turn_context")) continue; + let event; + try { event = JSON.parse(line); } catch { continue; } + const payload = event && typeof event === "object" ? event.payload : null; + if (event?.type === "turn_context" || payload?.type === "turn_context") { + const context = payload?.type === "turn_context" ? payload : payload ?? event; + model = safeModel(context.model ?? context.model_slug ?? context.modelName ?? event.model) ?? model; + } + if (event?.type !== "event_msg" && event?.type !== "token_count" && payload?.type !== "token_count") continue; + const info = payload?.info ?? event?.info; + const totalUsage = info?.total_token_usage ?? payload?.total_token_usage ?? event?.total_token_usage; + if (!totalUsage || typeof totalUsage !== "object") continue; + const input = safeCount(totalUsage.input_tokens); + const output = safeCount(totalUsage.output_tokens); + const reportedTotal = safeCount(totalUsage.total_tokens); + const total = reportedTotal ?? (input !== null && output !== null ? safeCount(input + output) : null); + if (total !== null) usage = { total, input, output }; + } + return { usage, size, modified: fs.fstatSync(descriptor).mtimeMs, model }; + } finally { fs.closeSync(descriptor); } + } + + let db; + try { + db = new DatabaseSync(workerData.database, { readOnly: true }); + db.exec("PRAGMA query_only = ON; PRAGMA busy_timeout = 500"); + const columns = new Set(db.prepare("PRAGMA table_info(threads)").all().map((row) => row.name)); + if (!columns.has("id")) throw new Error("Codex thread index is unavailable."); + const selected = ["id", "tokens_used", "model", "rollout_path", "updated_at", "updated_at_ms"] + .filter((column) => columns.has(column)).map((column) => '"' + column + '"'); + const row = db.prepare("SELECT " + selected.join(",") + " FROM threads WHERE id = ? LIMIT 1").get(workerData.threadId); + db.close(); + db = null; + if (!row) { parentPort.postMessage(null); return; } + + const databaseTotal = safeCount(row.tokens_used); + let rollout = null; + let fileSignature = ""; + if (typeof row.rollout_path === "string" && row.rollout_path.length <= 4096) { + const candidate = pathInsideHome(row.rollout_path); + if (candidate) { + try { + // Both sides use the OS resolver, as the host's realpath() does: the JavaScript resolver keeps + // Windows 8.3 short names (RUNNER~1) and drive-letter case, so a file inside the home looked outside it. + const realHome = fs.realpathSync.native(workerData.home); + const realFile = fs.realpathSync.native(candidate); + if (realFile.startsWith(realHome + path.sep)) { + const info = fs.statSync(realFile); + fileSignature = JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, realFile, info.size, info.mtimeMs]); + if (workerData.previousSignature === fileSignature) { + parentPort.postMessage({ unchanged: true, signature: fileSignature }); + return; + } + rollout = readLatestCounters(realFile); + } + } catch { /* The aggregate SQLite counter remains usable if the rollout has moved or is unavailable. */ } + } + } + + const total = rollout?.usage?.total ?? databaseTotal; + if (total === null) { parentPort.postMessage(null); return; } + const model = safeModel(rollout?.model) ?? safeModel(row.model); + const value = { + total, + input: rollout?.usage?.input ?? null, + output: rollout?.usage?.output ?? null, + ...(model ? { model } : {}) + }; + const signature = fileSignature || JSON.stringify([row.updated_at_ms ?? row.updated_at ?? null, databaseTotal, row.model ?? null, row.rollout_path ?? null]); + parentPort.postMessage({ usage: value, signature }); + } catch { + try { db?.close(); } catch { /* The worker has already failed. */ } + parentPort.postMessage(null); + } +})(); +`; + +function isWorkerUsageResult(value: unknown): value is WorkerUsageResult { + if (!value || typeof value !== "object") return false; + const result = value as WorkerUsageResult; + if (result.unchanged === true) return typeof result.signature === "string"; + if (!result.usage || typeof result.signature !== "string") return false; + const { total, input, output, model } = result.usage; + return Number.isSafeInteger(total) && total >= 0 + && (input === null || Number.isSafeInteger(input) && input >= 0) + && (output === null || Number.isSafeInteger(output) && output >= 0) + && (model === undefined || typeof model === "string" && model.length <= 200); +} diff --git a/src/main/services/ReviewDiffTracker.ts b/src/main/services/ReviewDiffTracker.ts new file mode 100644 index 000000000..647e6ae99 --- /dev/null +++ b/src/main/services/ReviewDiffTracker.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { isAbsolute, relative, resolve, sep } from "node:path"; + +export interface ReviewDiffBaseline { root: string; head: string; reason?: string } + +/** Host-owned attribution for managed PTYs. A shared or previously dirty tree cannot establish ownership. */ +export class ReviewDiffTracker { + private readonly active = new Map(); + private readonly baselines = new Map(); + + /** Synchronous by design: no other managed launch can interleave between this capture and spawnPty. */ + beforeSpawn(id: string, cwd: string, requested: boolean, parentCwd?: string): void { + this.forget(id); + let root: string | null = null; + try { root = this.root(cwd); } catch { /* A non-Git or inaccessible launch cannot supply a review diff. */ } + let canonicalCwd = resolve(cwd); + try { canonicalCwd = realpathSync(cwd); } catch { /* Retain the absolute path for conservative overlap checks. */ } + for (const baseline of this.baselines.values()) { + if (baseline.root === root || root === null && overlaps(canonicalCwd, baseline.root)) baseline.reason = "Another managed session started in this worktree; its changes cannot be attributed to this worker."; + } + const shared = [...this.active.values()].some(other => other.root === root || other.root === null && root !== null && overlaps(other.cwd, root)); + this.active.set(id, { root, cwd: canonicalCwd }); + if (!requested) return; + const baseline: ReviewDiffBaseline = { root: root ?? cwd, head: "" }; + this.baselines.set(id, baseline); + try { + if (!root) throw new Error("The worker's effective launch directory is not a verifiable Git worktree."); + if (!parentCwd || this.root(parentCwd) === root || shared) { + throw new Error("Automatic review requires a separate worktree; this worker shares its tree with its parent or another managed session."); + } + const safe = this.safeArguments(root); + if (this.git(root, [...safe, "ls-files", "--stage"]).split("\n").some(line => line.startsWith("160000 "))) { + throw new Error("Automatic review cannot attribute changes in a worktree containing submodules."); + } + if (this.git(root, [...safe, "status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=all"])) { + throw new Error("The worker worktree was already dirty at launch; automatic review cannot attribute those changes to the worker."); + } + const head = this.git(root, [...safe, "rev-parse", "--verify", "HEAD^{commit}"]); + if (!/^[a-f0-9]{40,64}$/u.test(head)) throw new Error("The worker's initial Git commit could not be verified."); + baseline.head = head; + } catch (error) { + baseline.reason = error instanceof Error ? error.message : "The worker review baseline could not be captured."; + } + } + + baseline(id: string): ReviewDiffBaseline { + const baseline = this.baselines.get(id); + if (!baseline) throw new Error("No worker-specific review baseline was captured for this launch."); + if (baseline.reason) throw new Error(baseline.reason); + if (this.root(baseline.root) !== baseline.root) throw new Error("The worker's Git worktree changed after launch."); + return baseline; + } + + assertCurrent(id: string, expected: ReviewDiffBaseline): void { + if (this.baseline(id) !== expected) throw new Error("The worker was restarted or removed while its diff was being read."); + } + + stopped(id: string): void { this.active.delete(id); } + forget(id: string): void { this.active.delete(id); this.baselines.delete(id); } + + private root(cwd: string): string { + return realpathSync(this.git(cwd, ["rev-parse", "--show-toplevel"])); + } + + private safeArguments(cwd: string): string[] { + let filters = ""; + try { filters = this.git(cwd, ["config", "--get-regexp", "^filter\\..*\\.(clean|smudge|process|required)$"]); } + catch (error) { if ((error as {status?: number}).status !== 1) throw error; } + return filters.split("\n").flatMap(line => { + const key = line.split(/\s/u, 1)[0]; + return /^filter\..*\.(clean|smudge|process|required)$/u.test(key) ? ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`] : []; + }); + } + + private git(cwd: string, args: string[]): string { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); + return execFileSync("git", ["--no-replace-objects", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", + "-c", "diff.external=", "-C", cwd, ...args], { + env: {...env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0"}, timeout: 1500, maxBuffer: 2 * 1024 * 1024, + encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] + }).trimEnd(); + } +} + +function overlaps(first: string, second: string): boolean { + const inside = (root: string, path: string): boolean => { + const rel = relative(root, path); + return rel === "" || !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`); + }; + return inside(first, second) || inside(second, first); +} diff --git a/src/main/services/SessionTimelineIndexWorker.ts b/src/main/services/SessionTimelineIndexWorker.ts new file mode 100644 index 000000000..7453a25b5 --- /dev/null +++ b/src/main/services/SessionTimelineIndexWorker.ts @@ -0,0 +1,8 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { scanTimelineDirectory } from "./TimelineIndexScan.ts"; + +if (!parentPort || typeof workerData?.directory !== "string") throw new Error("Session timeline index worker requires its journal directory."); +void scanTimelineDirectory(workerData.directory).then( + value => parentPort!.postMessage(value), + error => parentPort!.postMessage({ error: error instanceof Error ? error.message : "Timeline scan failed." }) +); diff --git a/src/main/services/SessionTimelineService.ts b/src/main/services/SessionTimelineService.ts new file mode 100644 index 000000000..c85a9976b --- /dev/null +++ b/src/main/services/SessionTimelineService.ts @@ -0,0 +1,615 @@ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { Worker, type WorkerOptions } from "node:worker_threads"; +import { applyTimelineBudgetUsageEvent, budgetCounterIdentity, isTimelineCount, MAX_INDEXED_SEGMENTS, nextTimelineUsage, scanTimelineDirectory, timelineUsageContext, + indexTimelineTaskSession, type BudgetCounterState, type BudgetUsageContribution, type TimelineDirectoryIndex, type TimelineIndexedUsage, type TimelineTaskSessionIndex, + type TimelineUsageContext } from "./TimelineIndexScan.ts"; +import { forEachTimelineEvent } from "./TimelineJournalReader.ts"; +import type { TimelineEvent, UsageSummary, UsageBreakdown, UsagePrice } from "../../shared/backlog.ts"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, timelineSegmentMayMatch, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; + +const SEGMENT_BYTES = 2 * 1024 * 1024; +const DEFAULT_MAX_BYTES = 200 * 1024 * 1024; +const MAX_EVENT_CHARS = 16_384; +const MAX_REPORT_EVENTS = 10_000; +export interface UsageContext extends TimelineUsageContext {} +export interface CumulativeUsage { input:number|null; output:number|null; total:number; costUsd?:number|null } +export interface TimelineUsageCounter { + id:string;legacyId?:string;tokens:number|null;costUsd:number|null;inputTokens:number|null;outputTokens:number|null; + provider?:string;model?:string;accountId?:string;taskId?:string;source:string;counterId?:string;cumulative:boolean; + reportedCostUsd:number|null; +} +export interface TimelineTaskBudgetUsage {identity:string;sessionId:string;taskId?:string;tokens:number;costUsd:number|null} +export interface TimelineTaskBudgetScope {taskId:string;sessionIds:readonly string[]} +export interface TimelineTaskBudgetData {sessionIds:string[];contributions:TimelineTaskBudgetUsage[]} +type Usage = TimelineIndexedUsage & { timelineOrder?:number }; +type TimelinePageCandidate = { event: TimelineEvent; index: number }; +type TimelinePageScope={taskId:string;legacySessionIds:string[]}; + +function createTimelineRing(capacity:number):{push(value:T):void;newestFirst():T[]} { + const values:Array=new Array(capacity); + let size=0,next=0; + return { + push(value:T) { + values[next]=value; + next=(next+1)%capacity; + if(size):string {return JSON.stringify([sessionId,row.source,row.counterId ?? ""]);} +function usageSession(key:string):string {return (JSON.parse(key) as string[])[0];} + +function usageSummary(values:Usage[],prices:UsagePrice[]):UsageSummary { + if (!values.length) return { tokens: {input: null, output: null, total: null}, cost: null, currency: "USD", source: null }; + const input = values.every(row=>row.input!==null) ? values.reduce((sum,row)=>sum+row.input!,0) : null; + const output = values.every(row=>row.output!==null) ? values.reduce((sum,row)=>sum+row.output!,0) : null; + const totals=values.map(totalTokens); + const total=totals.every((value):value is number=>value!==null) ? totals.reduce((sum,value)=>sum+value,0) : null; + const costs=values.map(row=>pricedCost(row,prices)); + return { tokens: {input, output, total}, + cost: costs.every((value):value is number=>value!==null) ? costs.reduce((sum,value)=>sum+value,0) : null, + currency: "USD", source: [...new Set(values.map((row) => row.source))].join(", ") }; +} + +function pricedCost(value:Usage,prices:UsagePrice[]):number|null { + if(typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0)return value.cost; + if(value.input===null || value.output===null || !value.provider || !value.model)return null; + const price=priceForUsage(value,prices); + return price ? (value.input*price.inputPerMillion+value.output*price.outputPerMillion)/1_000_000 : null; +} +function priceForUsage(value:Usage,prices:UsagePrice[]):UsagePrice|undefined { + return value.provider && value.model ? prices.find(row=>row.provider===value.provider && row.model===value.model) : undefined; +} +function addKnown(previous:number|null|undefined,current:number|null):number|null { + return current===null ? previous ?? null : (previous ?? 0)+current; +} +function counterDelta(current:number|null,previous:number|null):number|null { + return current===null || previous===null ? null : current>=previous ? current-previous : current; +} + +/** Hook events only: terminal output is never copied into this journal. */ +export class SessionTimelineService { + private queue: Promise = Promise.resolve(); + private segment = ""; + private segmentBytes = 0; + private totalBytes = 0; + private readonly usageBySession = new Map(); + private readonly usageKeysBySession = new Map>(); + private nextUsageOrder=0; + private budgetUsageContributions=new Map(); + private budgetCounterBaselines=new Map(); + private readonly usageListeners = new Set<(sessionId: string) => void>(); + private segmentIndexes = new Map(); + private taskSessionsByTask = new Map>(); + private sessionContextResolver?: (sessionId:string)=>{taskId:string;title:string}|undefined; + readonly directory: string; + private readonly redact: (text:string)=>string; + private readonly maxBytes: number; + private readonly now: ()=>number; + private lastSegmentAt = 0; + private pageCache: {name: string; size: number; mtimeMs: number; events: TimelineEvent[]} | null = null; + private fileListCache: string[] | null = null; + constructor(userDataPath: string, redact: (text: string) => string, + maxBytes = DEFAULT_MAX_BYTES, now = Date.now) { + this.redact=redact;this.maxBytes=maxBytes;this.now=now; + this.directory = join(userDataPath, "session-timeline"); + } + + configureSessionContext(resolver:(sessionId:string)=>{taskId:string;title:string}|undefined):void { + this.sessionContextResolver=resolver; + } + + async load(): Promise { + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + this.fileListCache = null; + this.pageCache = null; + this.totalBytes = await this.prune(); + // Scan journals incrementally on a worker. Startup only receives compact usage + // totals and bounded segment indexes, never hundreds of megabytes of events. + const index = await scanTimelineIndex(this.directory).catch(async error => { + console.warn("CanvasTTY timeline index worker unavailable; using the bounded journal scanner.", error instanceof Error ? error.message : "Worker failed."); + return scanTimelineDirectory(this.directory); + }).catch((error): TimelineDirectoryIndex => { + // Startup must not depend on old journals: without an index, usage totals start empty and pages read + // segments directly, while new events are still recorded. + console.warn("CanvasTTY timeline is degraded: its journal index is unavailable, so earlier usage totals are not shown.", + error instanceof Error ? error.message : "Scan failed."); + return {lastSegmentAt:0,usage:new Map(),budgetUsage:new Map(),budgetCounters:new Map(),segments:new Map(),taskSessions:new Map()}; + }); + this.lastSegmentAt = Math.max(this.lastSegmentAt, index.lastSegmentAt); + this.usageBySession.clear(); + this.usageKeysBySession.clear(); + this.nextUsageOrder=0; + for (const [key, usage] of index.usage) this.storeUsage(key, usageSession(key), usage); + this.budgetUsageContributions=index.budgetUsage; + this.budgetCounterBaselines=index.budgetCounters; + for(const [taskId,sessions] of index.taskSessions) { + for(const [sessionId,descriptor] of sessions) { + if(typeof descriptor.id!=="string" || descriptor.id!==sessionId || descriptor.taskId!==taskId + || typeof descriptor.title!=="string" || typeof descriptor.lastSegment!=="string" || !(descriptor.types instanceof Map)) { + sessions.delete(sessionId); + continue; + } + descriptor.title=this.redact(descriptor.title).slice(0,200); + } + if(!sessions.size)index.taskSessions.delete(taskId); + } + this.taskSessionsByTask=index.taskSessions; + this.segmentIndexes=index.segments; + } + + append(sessionId: string, type: string, summary: string, detail?: string, source?: string): Promise { + let context:{taskId:string;title:string}|undefined; + try { context=this.sessionContextResolver?.(sessionId); } catch { /* A closed or transient card keeps its ordinary session-only event. */ } + const taskId=context && typeof context.taskId==="string" && context.taskId.length>0 && context.taskId.length<=200 + ? context.taskId : undefined; + const sessionTitle=context && typeof context.title==="string" && context.title.length>0 + ? this.redact(context.title).slice(0,200) : undefined; + const event: TimelineEvent = { + id: randomUUID(), sessionId, at: this.now(), type, + summary: this.redact(summary).slice(0, 1000), + ...(detail !== undefined ? { detail: this.redact(detail).slice(0, MAX_EVENT_CHARS) } : {}), + ...(source ? { source: this.redact(source).slice(0, 100) } : {}), + ...(taskId ? {taskId} : {}), + ...(taskId && sessionTitle ? {sessionTitle} : {}) + }; + this.applyUsageEvent(event); + applyTimelineBudgetUsageEvent(this.budgetUsageContributions,this.budgetCounterBaselines,event); + const work = this.queue.then(async () => { + const line = JSON.stringify(event) + "\n"; + const bytes = Buffer.byteLength(line); + if (!this.segment || this.segmentBytes + bytes > Math.min(SEGMENT_BYTES, this.maxBytes)) { + this.lastSegmentAt=Math.max(this.now(),this.lastSegmentAt+1); + this.segment = `${String(this.lastSegmentAt).padStart(16, "0")}-${randomUUID()}.ndjson`; + this.segmentBytes = 0; + this.fileListCache = null; + } + await appendFile(join(this.directory, this.segment), line, { mode: 0o600 }); + this.pageCache = null; + this.segmentBytes += bytes; + this.totalBytes += bytes; + let index = this.segmentIndexes.get(this.segment); + if (!index) { + index = createTimelineSegmentSearchIndex(); + this.segmentIndexes.set(this.segment, index); + while (this.segmentIndexes.size > MAX_INDEXED_SEGMENTS) this.segmentIndexes.delete(this.segmentIndexes.keys().next().value!); + } + addTimelineSearchEvent(index, event); + index.size += bytes; + indexTimelineTaskSession(this.taskSessionsByTask,event,this.segment); + if (this.totalBytes > this.maxBytes) this.totalBytes = await this.prune(); + }); + this.queue = work.catch(() => undefined); + return work; + } + + /** Usage is accepted only from an identified real provider source, never synthesized from limits. */ + recordUsage(sessionId: string, input: number, output: number, source: string, cost?: number, context:UsageContext={}): Promise { + if (!isTimelineCount(input) || !isTimelineCount(output) + || !source || cost !== undefined && (!Number.isFinite(cost) || cost < 0)) { + return Promise.reject(new Error("Invalid provider usage record.")); + } + return this.append(sessionId, "usage", "Provider usage", JSON.stringify({ input, output, cost: cost ?? null, source,...timelineUsageContext(context) }), source); + } + + recordTokenTotal(sessionId:string,total:number,source:string,counterId?:string,context:UsageContext={}):Promise { + return this.recordCumulativeUsage(sessionId,{input:null,output:null,total},source,counterId,context); + } + + /** + * Replace a provider's cumulative counter; repeated lifecycle signals never add the same tokens twice. `resumed` + * says the card resumed this conversation: when the journal holds no earlier sample of its counter, this one is + * stored as a baseline, so usage from before the resume is not counted as this period's or this task's. + */ + recordCumulativeUsage(sessionId:string,usage:CumulativeUsage,source:string,counterId?:string,context:UsageContext={},options:{resumed?:boolean}={}):Promise { + const {input,output,total,costUsd}=usage; + if(!isTimelineCount(total) || input!==null && !isTimelineCount(input) || output!==null && !isTimelineCount(output) + || !source || costUsd!==undefined && costUsd!==null && (!Number.isFinite(costUsd) || costUsd<0) + || counterId!==undefined && (typeof counterId!=="string" || counterId.length>200))return Promise.reject(new Error("Invalid provider token counter.")); + const previous=this.usageBySession.get(usageKey(sessionId,{source,counterId})); + const cost=costUsd ?? null,normalizedContext=timelineUsageContext(context); + if(previous?.cumulative && previous.input===input && previous.output===output && previous.total===total + && previous.cost===cost && previous.source===source && previous.counterId===counterId + && Object.keys(normalizedContext).every(key=>previous[key as keyof UsageContext]===normalizedContext[key as keyof UsageContext]))return Promise.resolve(); + const baseline=options.resumed===true + && !this.budgetCounterBaselines.has(budgetCounterIdentity(sessionId,{input,output,total,cost,source,...(counterId!==undefined ? {counterId} : {}),...normalizedContext})); + return this.append(sessionId,"usage","CLI cumulative token counter",JSON.stringify({input,output,total,cost,source,cumulative:true,...(counterId ? {counterId} : {}),...normalizedContext,...(baseline ? {baseline:true} : {})}),source); + } + + async page(sessionId: string, cursor?: string, limit = 100,filter?:{query?:string;types?:string[];sessionIds?:string[]},scope?:TimelinePageScope): Promise<{items: TimelineEvent[]; nextCursor: string | null}> { + await this.queue; + const take = Math.min(500, Math.max(1, Math.floor(limit) || 100)); + const result: TimelineEvent[] = []; + const ids=new Set(filter?.sessionIds ?? [sessionId]),types=filter?.types?.length ? new Set(filter.types) : null,needle=filter?.query?.toLocaleLowerCase(); + const legacySessionIds=scope ? new Set(scope.legacySessionIds) : null; + const matches=(event:TimelineEvent):boolean=>ids.has(event.sessionId) + && (!types || types.has(event.type)) + && (!needle || `${event.summary}\n${event.detail ?? ""}`.toLocaleLowerCase().includes(needle)) + && (!scope || event.taskId===scope.taskId || event.taskId===undefined && legacySessionIds!.has(event.sessionId)); + const position = cursor?.startsWith("v1:") ? /^v1:(\d{16}-[\w-]+\.ndjson):(\d+)$/u.exec(cursor) : null; + if (cursor?.startsWith("v1:") && (!position || !Number.isSafeInteger(Number(position[2])))) throw new Error("Invalid timeline cursor."); + let passed = !cursor || Boolean(position); + let lastPosition = ""; + for (const name of [...(await this.files())].reverse()) { + if (position && name > position[1]) continue; + if (position && name === position[1] && Number(position[2]) === 0) continue; + const cachedPageSegment=this.pageCache?.name===name ? this.pageCache : null; + const recordedSegmentIndex=this.segmentIndexes.get(name); + const metadata=await stat(join(this.directory,name)); + if(cachedPageSegment + && (cachedPageSegment.size!==metadata.size || cachedPageSegment.mtimeMs!==metadata.mtimeMs))this.pageCache=null; + if(recordedSegmentIndex + && (recordedSegmentIndex.size!==metadata.size + || recordedSegmentIndex.mtimeMs!==0 && recordedSegmentIndex.mtimeMs!==metadata.mtimeMs)) { + this.segmentIndexes.delete(name); + if(this.segment===name){this.segment="";this.segmentBytes=0;} + } + const segmentIndex = this.segmentIndexes.get(name); + if (passed && segmentIndex && !timelineSegmentMayMatch(segmentIndex, ids, types, needle)) continue; + const segmentSize=metadata.size; + if(segmentSize>SEGMENT_BYTES) { + const capacity=take-result.length+1; + if(!passed) { + // Legacy ID cursors resolve to the newest matching ID in the newest segment + // that contains it, as the old reverse array walk did. Find that valid-row + // ordinal first so duplicate IDs need no page-sized snapshots while scanning. + let validIndex=0,latestCursorIndex:number|null=null; + await this.scan(name,event=>{ + if(event.id===cursor)latestCursorIndex=validIndex; + validIndex++; + }); + if(latestCursorIndex===null)continue; + passed=true; + const window=await this.scanPageWindow(name,capacity,latestCursorIndex,matches); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const cursorIndex=position && name===position[1] ? Number(position[2]) : undefined; + const window=await this.scanPageWindow(name,capacity,cursorIndex,matches); + if(cursorIndex!==undefined && cursorIndex>=window.validCount)throw new Error("Invalid timeline cursor."); + for(const candidate of window.candidates) { + if(result.length===take)return {items:result,nextCursor:lastPosition}; + result.push(candidate.event); + lastPosition=`v1:${name}:${candidate.index}`; + } + continue; + } + const events = await this.readPageSegment(name); + if (position && name === position[1] && Number(position[2]) >= events.length) throw new Error("Invalid timeline cursor."); + const start = position && name === position[1] ? Number(position[2]) - 1 : events.length - 1; + for (let index = start; index >= 0; index--) { + const event = events[index]; + if (!passed) { if (event.id === cursor) passed = true; continue; } + if (!matches(event)) continue; + if (result.length === take) return { items: result, nextCursor: lastPosition }; + result.push(event); + lastPosition = `v1:${name}:${index}`; + } + } + return { items: result, nextCursor: null }; + } + + usage(sessionIds?: string[],prices:UsagePrice[]=[]): UsageSummary { + const scope=sessionIds ? new Set(sessionIds) : null; + const values:Usage[]=scope ? [] : [...this.usageBySession.values()]; + if (scope) { + for(const sessionId of scope)for(const key of this.usageKeysBySession.get(sessionId) ?? []) { + const value=this.usageBySession.get(key);if(value)values.push(value); + } + if(scope.size>1)values.sort((left,right)=>(left.timelineOrder ?? 0)-(right.timelineOrder ?? 0)); + } + return usageSummary(values,prices); + } + + usageCounters(sessionId:string,prices:UsagePrice[]=[]):TimelineUsageCounter[] { + return [...(this.usageKeysBySession.get(sessionId) ?? [])].map(key=>{ + const value=this.usageBySession.get(key)!; + return {id:key,legacyId:value.counterId ?? sessionId,tokens:totalTokens(value),costUsd:pricedCost(value,prices), + inputTokens:value.input,outputTokens:value.output,provider:value.provider,model:value.model,accountId:value.accountId,taskId:value.taskId, + source:value.source,counterId:value.counterId,cumulative:value.cumulative===true,reportedCostUsd:value.cost}; + }); + } + + /** One traversal supplies all active task roots while preserving legacy session-only rows. */ + taskBudgetUsageByTask(scopes:readonly TimelineTaskBudgetScope[],prices:UsagePrice[]=[]):Map { + const sessionsByTask=new Map>(),rowsByTask=new Map>(); + for(const scope of scopes) { + let sessions=sessionsByTask.get(scope.taskId); + if(!sessions){sessions=new Set();sessionsByTask.set(scope.taskId,sessions);rowsByTask.set(scope.taskId,new Map());} + for(const sessionId of scope.sessionIds)sessions.add(sessionId); + } + const taskIds=new Set(sessionsByTask.keys()); + // Match sessionIds(taskId)'s historical ordering: explicit current members first, + // then usage-bearing sessions in usage map insertion order. + for(const [key,value] of this.usageBySession) { + const sessionId=usageSession(key); + if(taskIds.has(sessionId))sessionsByTask.get(sessionId)!.add(sessionId); + if(value.taskId!==undefined && taskIds.has(value.taskId))sessionsByTask.get(value.taskId)!.add(sessionId); + } + const legacyTasksBySession=new Map(); + for(const [taskId,sessions] of sessionsByTask)for(const sessionId of sessions) { + let tasks=legacyTasksBySession.get(sessionId); + if(!tasks){tasks=[];legacyTasksBySession.set(sessionId,tasks);} + tasks.push(taskId); + } + const add=(taskId:string,contribution:BudgetUsageContribution):void=>{ + const rows=rowsByTask.get(taskId)!; + const key=JSON.stringify([contribution.sessionId,contribution.identity,contribution.taskId ?? ""]); + let row=rows.get(key); + if(!row){row={identity:contribution.identity,sessionId:contribution.sessionId,...(contribution.taskId ? {taskId:contribution.taskId} : {}),tokens:0,costUsd:0,costKnown:true};rows.set(key,row);} + row.tokens+=contribution.tokens; + const cost=contribution.mode==="covered" ? 0 + : contribution.mode==="reported" || contribution.mode==="reported-base" ? contribution.reportedCostUsd + : contribution.input!==null && contribution.output!==null && contribution.provider && contribution.model + ? (()=>{const price=prices.find(item=>item.provider===contribution.provider && item.model===contribution.model);return price ? (contribution.input!*price.inputPerMillion+contribution.output!*price.outputPerMillion)/1_000_000 : null;})() + : null; + if(cost===null)row.costKnown=false;else row.costUsd+=cost; + }; + for(const contribution of this.budgetUsageContributions.values()) { + if(contribution.taskId!==undefined) { + if(taskIds.has(contribution.taskId))add(contribution.taskId,contribution); + } else for(const taskId of legacyTasksBySession.get(contribution.sessionId) ?? [])add(taskId,contribution); + } + const result=new Map(); + for(const [taskId,sessions] of sessionsByTask) { + const rows=rowsByTask.get(taskId)!; + result.set(taskId,{sessionIds:[...sessions],contributions:[...rows.values()].map(row=>({identity:row.identity,sessionId:row.sessionId, + ...(row.taskId ? {taskId:row.taskId} : {}),tokens:row.tokens,costUsd:row.costKnown ? row.costUsd : null}))}); + } + return result; + } + + /** Latest sessions with real usage, including closed members whose counters still belong to a task. */ + sessionIds(taskId?:string):string[] { + return [...new Set([...this.usageBySession].filter(([key,value])=>taskId===undefined || usageSession(key)===taskId || value.taskId===taskId).map(([key])=>usageSession(key)))]; + } + + /** Session and event-type facets derived only from retained, host-stamped journal rows. */ + taskSessions(taskId:string):Array<{id:string;title:string;types:string[]}> { + return [...(this.taskSessionsByTask.get(taskId)?.values() ?? [])] + .map(row=>({id:row.id,title:this.redact(row.title).slice(0,200),types:[...row.types.keys()].sort()})) + .sort((left,right)=>left.id.localeCompare(right.id)); + } + + /** Periods measure observed increases; when no baseline was ever persisted, the first sample is the available real counter. */ + async breakdown(period:"all"|"day"|"week",taskId?:string,prices:UsagePrice[]=[]):Promise { + await this.queue; + const cutoff=period==="all" ? 0 : this.now()-(period==="day" ? 86400_000 : 7*86400_000); + const counters=new Map(); + const rows=new Map(); + for(const file of await this.files())await this.scan(file,event=>{ + if(event.type!=="usage" || !event.detail)return; + let value:Usage;try{value=JSON.parse(event.detail);}catch{return;} + if(typeof value.source!=="string")return; + // A resumed provider conversation can move to a new CanvasTTY card: share its baseline by counter identity, + // while keeping the displayed delta attributed to the card that produced it. + const counterKey=value.counterId===undefined + ? JSON.stringify([event.sessionId,value.source]) + : JSON.stringify([value.provider ?? "",value.accountId ?? "",value.source,value.counterId]); + const rowKey=JSON.stringify([event.sessionId,counterKey]); + const base:UsageBreakdown={sessionId:event.sessionId,provider:value.provider ?? null,model:value.model ?? null,accountId:value.accountId ?? null,taskId:value.taskId ?? null, + tokens:{input:null,output:null,total:null},costUsd:null,costSource:null,source:value.source,period}; + if(value.cumulative===true) { + if(!isTimelineCount(value.total))return; + const current={input:isTimelineCount(value.input)?value.input:null,output:isTimelineCount(value.output)?value.output:null,total:value.total, + cost:typeof value.cost==="number" && Number.isFinite(value.cost) && value.cost>=0 ? value.cost : null}; + const previous=counters.get(counterKey);counters.set(counterKey,current); + if(event.at=0; + const cost=pricedCost(value,prices); + rows.set(rowKey,{...base,tokens:{input,output,total:input+output},costUsd:cost!==null && (!previousRow || previousRow.costUsd!==null) ? (previousRow?.costUsd ?? 0)+cost : null, + costSource:reported ? "reported" : price ? "human-price" : null}); + }); + return [...rows.values()]; + } + + async report(sessionId: string): Promise { + await this.queue; + const events: TimelineEvent[] = []; + let truncated = false; + for (const name of [...(await this.files())].reverse()) { + const capacity=MAX_REPORT_EVENTS-events.length; + const recent=createTimelineRing(capacity); + let seen=0,overflow=false; + await this.scan(name,event=>{ + if(event.sessionId!==sessionId)return; + if(capacity===0){truncated=true;return true;} + if(seen++===capacity)overflow=true; + recent.push(event); + }); + events.push(...recent.newestFirst()); + if(overflow)truncated=true; + if(truncated)break; + } + const sections: Array<[string, string[]]> = [ + ["Turns", ["lifecycle", "status"]], ["Tools, commands and files", ["tool", "command", "file"]], + ["Protection and isolation", ["decision", "isolation", "git-risk", "budget", "network-policy"]], + ["Human decisions", ["human", "secret-request", "secret-decision", "secret-revoked", "checkpoint"]] + ]; + const lines = ["# CanvasTTY session report", "", `Session: ${sessionId}`, "", + "Network visibility is incomplete: only addresses explicitly reported by hooks are known.", ""]; + for (const [title, types] of sections) { + lines.push(`## ${title}`, ""); + const matching = events.filter((event) => types.includes(event.type)).reverse(); + lines.push(...(matching.length ? matching.map((event) => `- ${new Date(event.at).toISOString()} ${event.summary}${event.detail ? ` — ${event.detail}` : ""}`) : ["No events were reported by this provider."]), ""); + } + if (truncated) lines.push("Report limited to the latest 10,000 events."); + return this.redact(lines.join("\n")); + } + + async flush(): Promise { await this.queue; } + subscribeUsage(listener: (sessionId: string) => void): () => void { + this.usageListeners.add(listener); + return () => this.usageListeners.delete(listener); + } + private applyUsageEvent(event: TimelineEvent): void { + const update = nextTimelineUsage(this.usageBySession, event); + if (!update) return; + const previous = this.usageBySession.get(update.key); + this.storeUsage(update.key, event.sessionId, update.value); + if (usageCountersChanged(previous, update.value)) this.notifyUsageChanged(event.sessionId); + } + private storeUsage(key:string,sessionId:string,value:Usage):void { + const previous=this.usageBySession.get(key); + this.usageBySession.set(key,{...value,timelineOrder:previous?.timelineOrder ?? this.nextUsageOrder++}); + let keys=this.usageKeysBySession.get(sessionId); + if(!keys){keys=new Set();this.usageKeysBySession.set(sessionId,keys);} + keys.add(key); + } + private notifyUsageChanged(sessionId:string):void { + for(const listener of this.usageListeners)try{listener(sessionId);}catch{ /* A refresh listener cannot break timeline persistence. */ } + } + private async files(): Promise { + if (this.fileListCache) return this.fileListCache; + this.fileListCache = (await readdir(this.directory)).filter((name) => /^\d{16}-[\w-]+\.ndjson$/.test(name)).sort(); + return this.fileListCache; + } + private async read(name: string): Promise { + const events: TimelineEvent[] = []; + await this.scan(name, event => { events.push(event); }); + return events; + } + private scan(name:string,visit:(event:TimelineEvent)=>boolean|void):Promise { + return forEachTimelineEvent(join(this.directory,name),visit); + } + private async scanPageWindow(name:string,capacity:number,beforeIndex:number|undefined, + matches:(event:TimelineEvent)=>boolean):Promise<{validCount:number;candidates:TimelinePageCandidate[]}> { + const ring=createTimelineRing(capacity); + let validCount=0; + await this.scan(name,event=>{ + const index=validCount++; + if((beforeIndex===undefined || index { + const cached = this.pageCache; + if (cached?.name === name) return cached.events; + const metadata = await stat(join(this.directory, name)); + const events = await this.read(name); + // Keep only one bounded segment. Large imported journals still page without retaining a large cache. + this.pageCache = metadata.size <= SEGMENT_BYTES ? {name, size:metadata.size, mtimeMs:metadata.mtimeMs, events} : null; + return events; + } + private async prune(): Promise { + const files = await this.files(); + const sizes = await Promise.all(files.map((name) => stat(join(this.directory, name)).then((s) => s.size))); + let total = sizes.reduce((sum, size) => sum + size, 0); + for (let i = 0; i < files.length && total > this.maxBytes; i++) { + await unlink(join(this.directory, files[i])); total -= sizes[i]; + this.fileListCache = null; + if (this.pageCache?.name === files[i]) this.pageCache = null; + this.segmentIndexes.delete(files[i]); + this.removeTaskSessionSegment(files[i]); + if (files[i] === this.segment) { this.segment = ""; this.segmentBytes = 0; } + } + return total; + } + + private removeTaskSessionSegment(segment:string):void { + for(const [taskId,sessions] of this.taskSessionsByTask) { + for(const [sessionId,descriptor] of sessions) { + for(const [type,lastSegment] of descriptor.types)if(lastSegment===segment)descriptor.types.delete(type); + // Pruning removes segments oldest-first, so no retained row exists for this actor when its latest segment is removed. + if(descriptor.lastSegment===segment)sessions.delete(sessionId); + } + if(!sessions.size)this.taskSessionsByTask.delete(taskId); + } + } +} + +function scanTimelineIndex(directory: string): Promise { + const built = fileURLToPath(new URL("./SessionTimelineIndexWorker.js", import.meta.url)); + const source = fileURLToPath(new URL("./SessionTimelineIndexWorker.ts", import.meta.url)); + const path = existsSync(built) ? built : existsSync(source) ? source : null; + if (!path) return Promise.reject(new Error("Session timeline index worker entry is missing.")); + const options: WorkerOptions = path.endsWith(".ts") ? { execArgv: ["--experimental-strip-types"] } : {}; + return new Promise((resolveResult, rejectResult) => { + const worker = new Worker(path, { ...options, workerData: { directory } }); + let finished = false; + const finish = (error: Error | null, value?: unknown): void => { + if (finished) return; + finished = true; + void worker.terminate(); + if (error) rejectResult(error); + else if (!isTimelineDirectoryIndex(value)) { + rejectResult(new Error("Session timeline index worker returned invalid data.")); + } else resolveResult(value); + }; + worker.once("message", (value: unknown) => { + if (!value || typeof value !== "object" || "error" in value) { + const message = value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : "Session timeline index worker failed."; + finish(new Error(message)); + return; + } + finish(null, value); + }); + worker.once("error", error => finish(error instanceof Error ? error : new Error("Session timeline index worker failed."))); + worker.once("exit", code => { + if (!finished && code !== 0) finish(new Error(`Session timeline index worker exited (${code}).`)); + else if (!finished) finish(new Error("Session timeline index worker stopped before returning its index.")); + }); + }); +} + +function isTimelineDirectoryIndex(value: unknown): value is TimelineDirectoryIndex { + if (!value || typeof value !== "object") return false; + const index=value as Partial; + return Number.isSafeInteger(index.lastSegmentAt) && index.usage instanceof Map && index.budgetUsage instanceof Map + && index.budgetCounters instanceof Map && index.segments instanceof Map && index.taskSessions instanceof Map; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be2969..705042fdc 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,4 +1,8 @@ +import { ReviewDiffTracker } from "./ReviewDiffTracker.ts"; +import { GitCheckpoints } from "./GitCheckpoints.ts"; +import { constants as osConstants } from "node:os"; import { randomUUID } from "node:crypto"; +import { stripVTControlCharacters } from "node:util"; import { realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; @@ -69,6 +73,7 @@ import { auditRepositories, neutralizeRepositories, type GitRiskRepository } fro import { LaunchRefusal } from "./launchRefusal.ts"; import { configuredMode } from "./configuredMode.ts"; import { envKey, RESERVED_ENV, type LaunchPipeline, type PreparedLaunch } from "./LaunchPipeline.ts"; +import { ACCOUNTS_PLUGIN_ID, selectedAccountId } from "./accountHomeIsolation.ts"; import type { EnvironmentRegistry } from "./EnvironmentRegistry.ts"; import { persistedTerminalSession, @@ -84,6 +89,8 @@ import { initialSessionStatus, type ProviderLifecycleParser } from "./providerLifecycle.ts"; +import { ProcessTreePause, type ProcessTreePauseResult } from "./ProcessTreePause.ts"; +import { isHostOwnedDiffOnlyReviewWorkspace, type DiffOnlyReviewWorkspace } from "./DiffOnlyReviewWorkspace.ts"; const MAX_SCROLLBACK_CHARS = 240_000; const OUTPUT_BATCH_MS = 16; @@ -101,6 +108,8 @@ const MIN_TERMINAL_SIZE = { width: 420, height: 260 }; const MAX_TERMINAL_SIZE = { width: 1_600, height: 1_100 }; interface ManagedSession { + accountId?:string; + accountHome?:string; metadata: SessionMetadata; process: IPty | null; cols: number; @@ -120,10 +129,18 @@ interface ManagedSession { /** The provider's own conversation id, once its hook reported it (or from the saved record). */ threadId?: string; captureResult: boolean; + captureReviewDiff?: boolean; + inputBracketedPaste?: boolean; /** Turns the agent started (its status became working) since launch. */ turnStarts?: number; /** turnStarts when the last submitted prompt was delivered; undefined while none was. */ promptTurnMark?: number; + /** Last accepted lifecycle state, also maintained when lifecycle UI updates are disabled. */ + acceptedLifecycleState?: ProviderLifecycleSignal["state"]; + inputGeneration?: number; + answerTurnGeneration?: number; + providerTurnId?: string; + providerTurnGenerations?: Map; /** The last turn's final answer its hook or plugin reported (captureResult only); cleared when a turn starts. */ answer?: { text: string; truncated: boolean; at: number }; /** @@ -135,14 +152,25 @@ interface ManagedSession { launchToken: number; /** Removes the current run's plugin files; called when the process exits. */ launchCleanup: (() => Promise) | null; + /** Cleanup already started by an earlier exit; closing the card must drain it before deleting its parent. */ + launchCleanupPending?: Promise; + /** Whole contributed launches, including asynchronous preparation/wrapping and abandoned-run cleanup. */ + launchTasks?: Set>; + /** The pipelines that created this card's run folders, retained across host reconfiguration/restart. */ + launchFilePipelines?: Set>; /** A restored grok card waits for its grid before launching; plugins still learn it is a restore. */ restoringLaunch: boolean; /** The environment was prepared or resumed in this run of the app, so it can be wrapped now. */ environmentReady: boolean; /** Bumped by every launch the person or the app asks for (create, restart, restore); input waits for one. */ launchEpoch: number; + /** Initial measured-grid launch was requested while its task budget was paused. */ + budgetDeferredLaunch?: boolean; /** Input waiting for this launch to start (deliverInput): woken whenever the launch moves on. */ launchWaiters: Set<() => void>; + inputQueue?: Promise; + cliInputReady?: boolean; + readinessOutput?: string; /** Brought back from the saved sessions at startup (plugins see a "restored" event, not "created"). */ restored?: boolean; /** What the CLI's own title last showed (Claude: spinner working, «✳» no turn running). */ @@ -151,6 +179,16 @@ interface ManagedSession { hookSignals?: number; /** Set after the person answered a hooked Claude prompt; see settleAnsweredPrompt. */ answeredPromptTimer?: ReturnType; + /** Removed when a transient diff-only review PTY exits or its card is disposed. */ + reviewWorkspace?: DiffOnlyReviewWorkspace; +} + +interface ReadOnlyReviewerLaunch { + taskRootSessionId: string; + workspace: DiffOnlyReviewWorkspace; + deniedReadPaths: string[]; + /** A worker's model account, prepared for this reviewer before its card exists (prepareReviewerAccount). */ + account?: { id: string; contribution: LaunchContribution }; } type EnvironmentService = Pick (() => void) | void>>(); + private readonly reviewDiffTracker = new ReviewDiffTracker(); private readonly spawnPty: typeof pty.spawn; + private readonly processTreePause: ProcessTreePause; + private readonly budgetPausedTaskRoots = new Set(); + private readonly budgetResumeWaiters = new Map void>>(); + /** + * The conversation each card's latest launch resumed: its id, or null until the provider reports which one a + * "resume latest" launch picked. Usage counted before the resume belongs to earlier runs, not to this card. + */ + private readonly resumedThreads = new Map(); // Renderer-reported card visibility, keyed by session and holding the // outputOffset at the moment it was hidden: the last offset the card saw. // Output keeps flowing through emit while hidden, addressed to the observers @@ -272,8 +320,12 @@ export class TerminalManager { private readonly gitRisks = new Map(); /** When isolated launches started whose card did not exist yet (see wrapIsolated). */ private readonly isolationStarts = new Map(); + /** Host-owned platform restriction found while wrapping a launch, before its card may exist. */ + private readonly pendingIsolationReasons = new Map(); // Plugin owners of cards being created (before the card exists), so their first launch counts as delegated. private readonly startingOwners = new Map(); + /** Reviewer-only launch authority is keyed by the exact host-created request object. */ + private readonly readOnlyReviewerRequests = new WeakMap(); // What a card's first launch found in its CLI's own configuration, before the card exists. private readonly pendingConfiguredModes = new Map(); // Whether the person acknowledged YOLO for a CLI (Settings: acknowledgedDangerousProfiles); unset allows it. @@ -282,6 +334,7 @@ export class TerminalManager { // the main process reads it from its emit callback to tell a failure that is // merely re-derived state from one the user just caused. private emittingFailureOrigin: FailureOrigin | null = null; + private inputGate: ((id:string)=>void) | null = null; constructor( emit: Emit, @@ -289,13 +342,15 @@ export class TerminalManager { agentBrowser?: AgentBrowserLaunchCoordinator, agentRuntime?: AgentRuntimeLaunchCoordinator, lifecycleHooksEnabled = true, - spawnPty: typeof pty.spawn = pty.spawn + spawnPty: typeof pty.spawn = pty.spawn, + processTreePause = new ProcessTreePause() ) { this.emit = emit; this.providerClis = providerClis; this.agentBrowser = agentBrowser; this.agentRuntime = agentRuntime; this.spawnPty = spawnPty; + this.processTreePause = processTreePause; this.lifecycleHooksEnabled = lifecycleHooksEnabled; } @@ -341,7 +396,247 @@ export class TerminalManager { configureRedaction(registry: SecretRedactionRegistry): void { this.redaction = registry; } + configureInputGate(gate: ((id:string)=>void) | null): void {this.inputGate=gate;} + + /** Host-only observer at the guarded PTY boundary; the returned callback runs only after a successful write. */ + observeInputWrites(id: string, observer: (data: string, submitted: boolean) => (() => void) | void): () => void { + if (!this.sessions.has(id)) throw new Error("Terminal session does not exist."); + let observers = this.inputWriteObservers.get(id); + if (!observers) { observers = new Set(); this.inputWriteObservers.set(id, observers); } + observers.add(observer); + return () => { + observers.delete(observer); + if (observers.size === 0 && this.inputWriteObservers.get(id) === observers) this.inputWriteObservers.delete(id); + }; + } + + /** + * Host-only entry point for an automatic reviewer. Its launch authority is tied to an unforgeable temporary + * workspace object and never becomes a field on the renderer/control request schema. + */ + /** + * Prepares the worker's model account (only the Accounts plugin's delegable options) for a diff-only reviewer, so + * the reviewer runs on the same key and model when the agent has no sign-in of its own. The reviewer still gets no + * agent or plugin tools; only the account's environment, arguments and run files reach its launch. + */ + async prepareReviewerAccount(input: { + taskRootSessionId: string; + provider: ProviderId; + workspace: DiffOnlyReviewWorkspace; + launchOptions: NonNullable; + }): Promise<{ id: string; contribution: LaunchContribution }> { + const pipeline = this.launchPipeline; + if (!pipeline) throw new LaunchRefusal("The model account of the worker is not available for its reviewer."); + const accountOnly = Object.fromEntries(Object.entries(input.launchOptions).filter(([pluginId]) => pluginId === ACCOUNTS_PLUGIN_ID)); + const options = pipeline.normalizeOptions(input.provider, accountOnly, { delegated: true }); + if (!options) throw new LaunchRefusal("The worker has no model account for its reviewer."); + const id = randomUUID(); + try { + const prepared = await pipeline.prepare({ + sessionId: id, + provider: input.provider, + profile: "plan", + role: "subagent", + cwd: realpathSync(input.workspace.directory), + projectRoot: this.taskScopeFor(input.taskRootSessionId).cwd, + parentSessionId: input.taskRootSessionId, + restoring: false, + resume: false, + options: structuredClone(options) as Record>, + environment: null + }); + if (!prepared.ok) throw new LaunchRefusal(prepared.reason); + // A reviewer id has one run and cannot be restarted. Its account contribution therefore owns both the run + // and its parent even before a card adopts it; cancellation/failed creation use this same cleanup handle. + const cleanup = prepared.cleanup; + let released: Promise | undefined; + prepared.cleanup = () => released ??= (async () => { + try { await cleanup(); } + finally { await pipeline.forgetSession(id); } + })(); + return { id, contribution: prepared }; + } catch (error) { + await pipeline.forgetSession(id).catch(() => undefined); + throw error; + } + } + + createReadOnlyReviewer(input: { + taskRootSessionId: string; + provider: ProviderId; + /** The reviewer's model; omitted when the worker's model account (prepareReviewerAccount) decides it. */ + model?: string; + account?: { id: string; contribution: LaunchContribution }; + title: string; + workspace: DiffOnlyReviewWorkspace; + }): SessionSnapshot { + if (input.provider === "terminal" || (!input.model && !input.account) || !isHostOwnedDiffOnlyReviewWorkspace(input.workspace)) { + throw new LaunchRefusal("The diff-only reviewer workspace is unavailable; the reviewer was not started."); + } + const parent = this.sessions.get(input.taskRootSessionId); + if (!parent) throw new LaunchRefusal("The review task ended before its isolated reviewer could start."); + const cwd = realpathSync(input.workspace.directory); + if (cwd !== input.workspace.directory) throw new LaunchRefusal("The diff-only reviewer workspace changed before launch."); + + const denied = new Set(); + const addDirectory = (candidate: string): void => { + let real: string; + try { + real = realpathSync(candidate); + if (!statSync(real).isDirectory()) throw new Error("not a directory"); + } catch { + throw new LaunchRefusal("A project or session root could not be verified for diff-only review."); + } + if (isPathInside(cwd, real) || isPathInside(real, cwd)) { + throw new LaunchRefusal("The diff-only reviewer workspace overlaps a project or session root."); + } + denied.add(real); + }; + const taskRoot = this.taskScopeFor(input.taskRootSessionId); + addDirectory(taskRoot.cwd); + for (const session of this.sessions.values()) { + addDirectory(session.metadata.cwd); + addDirectory(session.metadata.taskScope?.cwd ?? this.taskScopeFor(session.metadata.id).cwd); + } + if (denied.size === 0) throw new LaunchRefusal("No project roots were available to isolate the diff-only reviewer."); + + const request: CreateSessionRequest = { + provider: input.provider, + cwd, + profile: "plan", + title: input.title.slice(0, 80), + ...(input.model ? { model: input.model } : {}), + position: parent.metadata.position, + role: "subagent", + parentSessionId: input.taskRootSessionId + }; + this.readOnlyReviewerRequests.set(request, { + taskRootSessionId: input.taskRootSessionId, + workspace: input.workspace, + deniedReadPaths: [...denied], + ...(input.account ? { account: input.account } : {}) + }); + try { + return this.create(request, { origin: "subagent", continueTaskFrom: input.taskRootSessionId }); + } catch (error) { + this.readOnlyReviewerRequests.delete(request); + throw error; + } + } + + /** + * The model account this card was launched on (the Accounts plugin's selected account), or undefined when it runs on + * its CLI's own sign-in. A subagent inherits it unless its spawn names an account (AgentControlService). + */ + modelAccountOf(id: string): string | undefined { + const options = this.sessions.get(id)?.extras.options; + const account = selectedAccountId(options); + return account === "default" ? undefined : account; + } + + setTaskMetadata(id: string, patch: Pick): void { + const session=this.sessions.get(id);if (!session) return; + if (patch.reviewRequested !== undefined) session.metadata.reviewRequested=patch.reviewRequested; + this.schedulePersistence(); + this.emitSession(session.metadata); + } + setTaskBudget(id:string,value:SessionMetadata["taskBudget"]):void { + const session=this.sessions.get(id);if(!session || JSON.stringify(session.metadata.taskBudget)===JSON.stringify(value))return; + if(value)session.metadata.taskBudget={...value};else delete session.metadata.taskBudget; + this.emitSession(session.metadata); + } + /** Host-only process suspension; Windows explicitly reports that an active process tree cannot be paused. */ + setBudgetPaused(taskRootId:string,paused:boolean):ProcessTreePauseResult { + if(paused)this.budgetPausedTaskRoots.add(taskRootId); + if(!paused && !this.processTreePause.supported) { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + return {supported:true}; + } + const failures:string[]=[]; + let supported=!paused || this.processTreePause.supported; + const resumedGroups:IPty[]=[]; + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id!==taskRootId || !session.process)continue; + const wasPaused=this.processTreePause.isPaused(session.process); + const result=paused ? this.processTreePause.pause(session.process) : this.processTreePause.resume(session.process); + supported=supported && result.supported; + if(result.failed)failures.push(result.failed); + else if(!paused && wasPaused)resumedGroups.push(session.process); + } + if(paused) { + if(!supported && !failures.length)failures.push("The platform cannot safely suspend an active PTY process tree."); + } else if(failures.length) { + // A failed SIGCONT must not make a stopped task appear runnable. Keep the host gate closed so input and + // child/restart launches cannot run ahead of a process group that may still be stopped. Re-stop every + // group already resumed in this batch, so one failed resume cannot leave the rest of the task running. + for(const process of resumedGroups) { + const rollback=this.processTreePause.pause(process); + if(rollback.failed)failures.push(`Could not re-suspend a process group after another group failed to resume: ${rollback.failed}`); + } + this.budgetPausedTaskRoots.add(taskRootId); + } else { + this.budgetPausedTaskRoots.delete(taskRootId); + this.wakeBudgetResumeWaiters(taskRootId); + this.launchDeferredBudgetSessions(taskRootId); + } + return {supported,...(failures.length ? {failed:[...new Set(failures)].join(" ")} : {})}; + } + + /** False on Windows: a budget pause there blocks input and launches but cannot stop running processes. */ + processSuspensionSupported():boolean {return this.processTreePause.supported;} + + /** Startup orchestration replays persisted paused roots before restoring PTYs. */ + isBudgetPaused(taskRootId:string):boolean {return this.budgetPausedTaskRoots.has(taskRootId);} + + private wakeBudgetResumeWaiters(taskRootId:string):void { + for(const wake of [...(this.budgetResumeWaiters.get(taskRootId) ?? [])])wake(); + this.budgetResumeWaiters.delete(taskRootId); + } + private launchDeferredBudgetSessions(taskRootId:string):void { + for(const session of this.sessions.values()) { + if(this.taskScopeFor(session.metadata.id).id===taskRootId && session.budgetDeferredLaunch) { + session.budgetDeferredLaunch=false; + this.launchAwaitingSession(session.metadata.id,session); + } + } + } + + taskScopeFor(sourceId:string):NonNullable { + const source=this.sessions.get(sourceId);if(!source)throw new Error("Handoff card is unavailable."); + let root=source; + const seen=new Set([sourceId]); + while(root.metadata.parentSessionId && !seen.has(root.metadata.parentSessionId)) { + seen.add(root.metadata.parentSessionId);const parent=this.sessions.get(root.metadata.parentSessionId);if(!parent)break;root=parent; + } + const scope=root.metadata.taskScope ?? {id:root.metadata.id,cwd:root.metadata.cwd,startedAt:root.metadata.startedAt}; + return {...scope}; + } + private isSessionBudgetPaused(session:ManagedSession):boolean { + try {return this.budgetPausedTaskRoots.has(this.taskScopeFor(session.metadata.id).id);} catch {return false;} + } + private async awaitBudgetResume(session:ManagedSession):Promise { + while(this.isSessionBudgetPaused(session)) { + if(this.sessions.get(session.metadata.id)!==session || session.metadata.exitCode!==null)return false; + const taskRootId=this.taskScopeFor(session.metadata.id).id; + await new Promise(resolve=>{ + const waiters=this.budgetResumeWaiters.get(taskRootId) ?? new Set<() => void>(); + this.budgetResumeWaiters.set(taskRootId,waiters); + const wake=()=>{ + waiters.delete(wake); + session.launchWaiters.delete(wake); + if(waiters.size===0)this.budgetResumeWaiters.delete(taskRootId); + resolve(); + }; + waiters.add(wake); + session.launchWaiters.add(wake); + if(!this.budgetPausedTaskRoots.has(taskRootId))wake(); + }); + } + return this.sessions.get(session.metadata.id)===session && session.metadata.exitCode===null; + } /** Masks known secrets and key shapes in text another agent reads (observe, result, control screen, failures). */ redactSecrets(text: T): T { return (text === null ? text : this.redaction.redact(text)) as T; @@ -384,6 +679,17 @@ export class TerminalManager { }; } + /** Host-only attribution from the accepted Accounts contribution; paths never enter plugin summaries. */ + /** Whether this card's latest launch resumed the provider conversation `threadId` (rather than starting it). */ + resumedConversation(id:string,threadId:string):boolean { + return this.resumedThreads.get(id)===threadId; + } + + usageAccount(id:string):{id:string;home?:string} { + const session=this.sessions.get(id); + return {id:session?.accountId ?? "default",...(session?.accountHome ? {home:session.accountHome} : {})}; + } + /** Records the plugin that started a card (EP-4); saved with the card so control survives a restore. */ setPluginOwner(id: string, pluginId: string): void { const session = this.sessions.get(id); @@ -605,8 +911,10 @@ export class TerminalManager { create( request: CreateSessionRequest, - control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string } = {} + control: { captureReviewDiff?: boolean; captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string; continueTaskFrom?:string } = {} ): SessionSnapshot { + const reviewerControl = this.readOnlyReviewerRequests.get(request); + if (reviewerControl) this.readOnlyReviewerRequests.delete(request); assertCreateRequest(request, this.containment()); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; if (request.profile === "yolo" && request.provider !== "terminal") { @@ -625,6 +933,11 @@ export class TerminalManager { } const resume: ResumeRequest = threadId ? { threadId } : null; const modelChoice = launchModelChoice(request.provider, request.model, request.effort); + // A model account passes its own --model; a second one from the request made OpenCode crash at start + // ("U.split is not a function"). The account decides the model: say so instead of starting a broken CLI. + if (modelChoice.model !== undefined && selectedAccountId(request.launchOptions) !== "default") { + throw new LaunchRefusal(`Model account ${selectedAccountId(request.launchOptions).slice(0, 40)} sets the model of this launch; omit model, or choose an account for the model you want (list_routes / pick_account).`); + } if (modelChoice.model !== undefined) { let unknown: string | null = null; try { unknown = this.modelCheck(request.provider, modelChoice.model); } catch { unknown = null; } @@ -641,19 +954,35 @@ export class TerminalManager { } const delegated = origin === "subagent" || control.ownerPluginId !== undefined; - const launchOptions = this.launchPipeline - ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" ? { delegated: true } : undefined) + // The orchestrator's own account is the person's choice: its subagent may run on it even where the plugin did not + // declare its options delegable. Any other account needs the plugin's delegable declaration. + const parentAccount = origin === "subagent" && request.parentSessionId !== undefined ? this.modelAccountOf(request.parentSessionId) : undefined; + const launchOptions = reviewerControl ? undefined : this.launchPipeline + ? this.launchPipeline.normalizeOptions(request.provider, request.launchOptions, origin === "subagent" + ? { delegated: true, ...(parentAccount ? { inherited: { [ACCOUNTS_PLUGIN_ID]: { account: parentAccount } } } : {}) } : undefined) : request.launchOptions === undefined ? undefined : failWith("Plugin launch options are not available."); // The isolation layer decides before anything starts: a subagent without it runs in normal, a launch that // needs it and cannot have it is refused. - const decision = this.decideIsolation(request.provider, request.profile, delegated, null); + // Host consent supplies continuation identity before any process or capability is launched. + const continuationScope=control.continueTaskFrom ? this.taskScopeFor(control.continueTaskFrom) : undefined; + const inheritedTaskRootId=continuationScope?.id ?? (request.parentSessionId ? this.taskScopeFor(request.parentSessionId).id : undefined); + if(inheritedTaskRootId && this.budgetPausedTaskRoots.has(inheritedTaskRootId)) { + throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before starting another agent."); + } + const taskProjectRoot = continuationScope?.cwd ?? this.taskProjectRoot(request.cwd, request.parentSessionId); + const decision = this.decideIsolation(request.provider, request.profile, delegated, null, taskProjectRoot); if (decision.refuse) throw new LaunchRefusal(decision.refuse); + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + throw new LaunchRefusal("A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started."); + } if (decision.profile !== request.profile) request = { ...request, profile: decision.profile }; - const environmentChoice = this.environments + const environmentChoice = reviewerControl ? null : this.environments ? this.environments.normalizeChoice(request.provider, request.environment) ?? null : request.environment === undefined ? null : failWith("Plugin environments are not available."); - const id = randomUUID(); + const id = reviewerControl?.account?.id ?? randomUUID(); + const startedAt=Date.now(); + const captureResult = control.captureResult === true || Boolean(reviewerControl); const metadata: SessionMetadata = { id, revision: 0, @@ -667,16 +996,17 @@ export class TerminalManager { role, ...(request.parentSessionId !== undefined ? { parentSessionId: request.parentSessionId } : {}), status: initialSessionStatus(request.provider), - startedAt: Date.now(), + startedAt, + ...(continuationScope ? {taskScope:continuationScope} : environmentChoice && !request.parentSessionId ? {taskScope:{id,cwd:request.cwd,startedAt}} : {}), exitCode: null, failureDetails: null, ...modelChoice, ...(decision.isolation ? { isolation: decision.isolation } : {}) }; - const awaitMeasuredGrid = request.provider === "grok" + const awaitMeasuredGrid = !reviewerControl && request.provider === "grok" && this.providerClis.get(request.provider).state === "available"; // With launch options, an environment or a launch policy the plugins answer first; the card waits and launches when they do. - const contributed = (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; + const contributed = !reviewerControl && (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; this.startingModels.set(id, modelChoice); if (control.ownerPluginId !== undefined) this.startingOwners.set(id, control.ownerPluginId); let launched: ReturnType | { process: null; agentBrowser: null; agentRuntime: null; agentOrchestration: null; failure: null }; @@ -684,8 +1014,8 @@ export class TerminalManager { launched = awaitMeasuredGrid || contributed ? { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null, failure: null } : this.spawnProcess(id, request.provider, request.profile, request.cwd, - INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, control.captureResult, role, - control.answerCaptureGrantExpiresAt, null, request.parentSessionId); + INITIAL_TERMINAL_COLS, INITIAL_TERMINAL_ROWS, resume, captureResult, role, + control.answerCaptureGrantExpiresAt, reviewerControl?.account?.contribution ?? null, request.parentSessionId, taskProjectRoot, reviewerControl, control.captureReviewDiff === true); } finally { this.startingModels.delete(id); this.startingOwners.delete(id); @@ -715,7 +1045,9 @@ export class TerminalManager { awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: resume, ...(threadId ? { threadId } : {}), - captureResult: control.captureResult === true, + captureResult, + captureReviewDiff: control.captureReviewDiff === true, + ...(reviewerControl ? { reviewWorkspace: reviewerControl.workspace } : {}), extras: { ...(launchOptions ? { options: launchOptions } : {}), ...(environmentChoice ? { environmentChoice } : {}), @@ -729,6 +1061,13 @@ export class TerminalManager { launchWaiters: new Set() }; this.sessions.set(id, session); + const reviewerAccount = reviewerControl?.account?.contribution; + if (reviewerAccount) { + this.addLaunchSecrets(session, reviewerAccount.secrets); + session.accountId = reviewerAccount.accountId ?? reviewerControl!.account!.id; + session.accountHome = reviewerAccount.accountHome; + session.launchCleanup = reviewerAccount.cleanup; + } this.adoptIsolationStart(id, session); if (launched.process) this.bindProcess(id, session, launched.process); if (contributed) this.launchContributed(id, session, resume, null, control.answerCaptureGrantExpiresAt); @@ -748,23 +1087,134 @@ export class TerminalManager { return session ? snapshot(session) : null; } - restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + private readonly retryingLaunches = new Set(); + private readonly retryLaunchValidators = new Map void>(); + + /** Replace the process, retaining this card's task ownership, environment and diagnostic scrollback. */ + async retryAgentLaunch(id: string, prompt: string, validate: () => void, signal?: AbortSignal, beforeStop?: () => void): Promise { const session = this.sessions.get(id); - if (!session) throw new Error("Terminal session does not exist."); - if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); + if (!session || session.metadata.role !== "subagent") throw new LaunchRefusal("Only an existing subagent can be retried."); + if (this.retryingLaunches.has(id)) throw new LaunchRefusal("A retry of this agent is already in progress."); + const ancestors: Array<{ session: ManagedSession; epoch: number }> = []; + const seen = new Set([id]); + for (let parentId = session.metadata.parentSessionId; parentId;) { + if (seen.has(parentId)) throw new LaunchRefusal("The subagent's parent lineage is invalid."); + seen.add(parentId); + const parent = this.sessions.get(parentId); + if (!parent) throw new LaunchRefusal("The subagent's parent was closed."); + ancestors.push({ session: parent, epoch: parent.launchEpoch }); + parentId = parent.metadata.parentSessionId; + } + let epoch = session.launchEpoch; + const current = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch; + const check = (): void => { + if (signal?.aborted) throw new DOMException("The retry was canceled.", "AbortError"); + if (!current() || ancestors.some(({ session: parent, epoch: parentEpoch }) => + this.sessions.get(parent.metadata.id) !== parent || parent.launchEpoch !== parentEpoch)) { + throw new LaunchRefusal("The agent or its parent was closed or restarted during retry."); + } + validate(); + this.validateRestart(session); + assertDirectory(session.metadata.cwd); + if (session.metadata.provider === "terminal" || this.providerClis.get(session.metadata.provider).state !== "available") throw new LaunchRefusal("The agent CLI is unavailable."); + }; + const previousFailure = session.metadata.failureDetails; + let replacementStarted = false; + this.retryingLaunches.add(id); + try { + check(); + beforeStop?.(); + // Never start a successor while the old PTY might still be alive. A timeout keeps its live slot occupied. + await this.stopRetryProcess(session); + check(); + // restartSession advances the epoch synchronously before starting either launch path. Set the expected + // epoch first so synchronous spawn failures are ours to clean up, and contributed guards see it immediately. + epoch += 1; + replacementStarted = true; + this.retryLaunchValidators.set(id, check); + this.restartSession(id); + const delivery = await this.deliverInput(id, `${prompt}\r`, undefined, signal); + check(); + if (!delivery.delivered) throw new LaunchRefusal(`The retry prompt was not delivered: ${delivery.reason}`); + return publicSessionMetadata(session); + } catch (error) { + if (replacementStarted && current()) { + // Stop only our replacement. Closing/restarting elsewhere must never let this cleanup kill a later launch. + try { await this.stopRetryProcess(session); } + catch (stopError) { + throw new LaunchRefusal(`Retry failed and its process could not be stopped: ${this.redactSecrets(String(stopError))}`); + } + if (current()) { + session.metadata.status = "failed"; + session.metadata.exitCode = session.metadata.exitCode || 1; + session.metadata.failureDetails = this.redactSecrets([ + previousFailure, "The previous process was stopped; it was not restored.", + `Retry failed: ${error instanceof Error ? error.message : String(error)}` + ].filter(Boolean).join("\n")).slice(0, 8_000); + this.emitSession(session.metadata); + this.schedulePersistence(); + } + } + throw error; + } finally { + this.retryingLaunches.delete(id); + this.retryLaunchValidators.delete(id); + } + } + + private async stopRetryProcess(session: ManagedSession): Promise { + const process = session.process; + if (process) { + const exited = this.liveProcesses.get(process); + if (!exited) throw new LaunchRefusal("The agent process has no exit confirmation."); + const resumed = this.processTreePause.resume(process); + if (resumed.failed) throw new LaunchRefusal("The paused agent could not be resumed for termination."); + process.kill(); + let timer: ReturnType | undefined; + try { + await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new LaunchRefusal("The old agent has not exited; no replacement was started.")), 5_000); + })]); + } finally { clearTimeout(timer); } + } else if (session.metadata.exitCode === null) { + // Fence asynchronous plugin preparation even when no PTY has been adopted yet. + session.launchToken += 1; + this.wakeLaunchWaiters(session); + session.metadata.exitCode = 1; + session.metadata.status = "failed"; + session.metadata.failureDetails = session.metadata.failureDetails ?? "The pending launch was stopped before retry completed."; + void this.cleanupLaunchFiles(session).catch(() => undefined); + this.emitSession(session.metadata); + this.schedulePersistence(); + } + } + + private validateRestart(session: ManagedSession): void { + if (session.reviewWorkspace) throw new LaunchRefusal("A diff-only reviewer session cannot be restarted; request a new isolated review instead."); + if (this.isSessionBudgetPaused(session)) throw new LaunchRefusal("This task's usage budget is paused. Clear or raise the task budget before restarting it."); const environment = session.extras.environment; if (environment && !this.environmentUsable(environment)) { - // Never run a placed session locally instead of where it belongs. throw new Error(`This card runs in ${environment.label} from plugin ${environment.pluginId}, which is not available. It was not started locally.`); } - const pendingChoice = session.extras.environment ? undefined : session.extras.environmentChoice; - if (pendingChoice && !this.environments?.available(pendingChoice)) { - throw new Error(`${this.pendingEnvironmentReason(pendingChoice)} It was not started locally.`); - } + const pendingChoice = environment ? undefined : session.extras.environmentChoice; + if (pendingChoice && !this.environments?.available(pendingChoice)) throw new Error(`${this.pendingEnvironmentReason(pendingChoice)} It was not started locally.`); const missingPlugins = this.unavailableLaunchPlugins(session.extras.options); if (missingPlugins.length > 0) throw new Error(`Launch refused: ${missingLaunchPlugins(missingPlugins)}`); + } + + restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + if (this.retryingLaunches.has(id)) throw new LaunchRefusal("A retry of this agent is already in progress."); + return this.restartSession(id, options); + } + + private restartSession(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + const session = this.sessions.get(id); + if (!session) throw new Error("Terminal session does not exist."); + this.validateRestart(session); + if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); delete session.extras.heldState; delete session.metadata.restoreNote; + this.reviewDiffTracker.forget(id); // Input queued for the launch that ended never reaches this one. session.launchEpoch += 1; this.wakeLaunchWaiters(session); @@ -876,15 +1326,39 @@ export class TerminalManager { return Boolean(session && session.metadata.exitCode === null && !session.process); } + /** Host input readiness includes OpenCode's first prompt, before it creates a conversation. */ + inputReady(id: string): boolean { + const session = this.sessions.get(id); + return Boolean(session?.process && session.metadata.exitCode === null + && (session.hookSignals || session.titleState || session.cliInputReady)); + } + /** - * The one delivery rule for text another agent, a plugin or a controller sends to a card (spawn_agent's first - * prompt, send_to_agent, plugin sessions.send). A running card gets it at once. A card whose launch plugins are - * still preparing (launch options, a launch policy, an environment) or that waits for its grid gets it exactly - * once, when that launch has started. A launch that is refused, fails, is cancelled or superseded (closed, - * restarted), or does not start within LAUNCH_INPUT_WAIT_MS delivers nothing, says why, and drops the text: - * it never reaches a later launch of the card. An aborted `signal` (the sender cancelled) delivers nothing either. + * Serialize controller prompts within one launch, waiting for the PTY and supported CLI readiness. + * Confirm hooked CLI input through a new turn or an echoed unsubmitted prompt. Submitted text is + * written once; only Enter may be retried after a complete echo. A failed acknowledgement is ambiguous + * and asks the caller to inspect the card. Cancellation, restart and deadlines never replay queued text + * into a later launch. Unhooked terminals retain ordinary PTY delivery. */ - async deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { + deliverInput(id: string, data: string, waitMs = LAUNCH_INPUT_WAIT_MS, signal?: AbortSignal): Promise { + const session = this.sessions.get(id); + if (!session) return Promise.resolve({ delivered: false, reason: "The session does not exist." }); + const epoch = session.launchEpoch; + const deadline = Date.now() + waitMs; + const previous = session.inputQueue; + const run = async (): Promise => { + if (this.sessions.get(id) !== session || session.launchEpoch !== epoch) { + return { delivered: false, reason: "The session was closed or restarted before delivery." }; + } + return this.deliverQueuedInput(id, data, Math.max(0, deadline - Date.now()), signal); + }; + const pending = previous ? previous.then(run, run) : run(); + session.inputQueue = pending; + void pending.finally(() => { if (session.inputQueue === pending) delete session.inputQueue; }).catch(() => {}); + return pending; + } + + private async deliverQueuedInput(id: string, data: string, waitMs: number, signal?: AbortSignal): Promise { const canceled: InputDelivery = { delivered: false, reason: "The delivery was cancelled." }; if (signal?.aborted) return canceled; const session = this.sessions.get(id); @@ -918,23 +1392,84 @@ export class TerminalManager { ? `The session did not start: ${this.redactSecrets(session.metadata.failureDetails)}` : "The session has already exited." }; } + // Only these providers have a startup hook and a turn acknowledgement. Without an installed runtime + // (e.g. hooks disabled or a remote environment) keep the ordinary PTY delivery contract. + const confirm = (this.lifecycleHooksEnabled || session.captureResult) && session.agentRuntime !== null + && ["opencode", "claude", "codex"].includes(session.metadata.provider); + const valid = (): boolean => this.sessions.get(id) === session && session.launchEpoch === epoch + && session.metadata.exitCode === null && !signal?.aborted; + const poll = async (): Promise => { + await new Promise((resolve) => { + const wake = (): void => { clearTimeout(timer); signal?.removeEventListener("abort", wake); resolve(); }; + const timer = setTimeout(wake, Math.max(0, Math.min(50, deadline - Date.now()))); + signal?.addEventListener("abort", wake, { once: true }); + }); + }; + if (confirm) { + while (valid() && !this.inputReady(id) && Date.now() < deadline) await poll(); + if (signal?.aborted) return canceled; + if (!valid()) return { delivered: false, reason: "The session closed, exited or restarted before CLI readiness." }; + if (!this.inputReady(id)) return { delivered: false, reason: "The CLI did not become ready before the delivery deadline; no text was sent." }; + } + if (Date.now() >= deadline) return { delivered: false, reason: "The input delivery deadline expired; no text was sent." }; // A submitted prompt: the agent's next turn is the one that answers it (turnProgress). + const offset = session.outputOffset; const mark = session.turnStarts ?? 0; if (!this.inputChecked(id, data)) return { delivered: false, reason: "The terminal no longer accepts input." }; - if (data.endsWith("\r")) session.promptTurnMark = mark; - return { delivered: true }; + if (!confirm) return { delivered: true }; + // Never replay the text on an ambiguous acknowledgement: that can run a task twice. Retry only Enter, + // and only after observing the complete text echoed by an idle CLI (the text itself was accepted). + const text = data.replace(/[\r\n]$/u, "").replace(/\s+/gu, ""); + let submits = 1; + let retryAt = Date.now() + 1000; + let observedOffset = offset; + let echoed = false; + while (valid() && Date.now() < deadline) { + if ((session.turnStarts ?? 0) > mark) return { delivered: true }; + if (session.outputOffset !== observedOffset) { + observedOffset = session.outputOffset; + const fresh = scrollbackTail(session, observedOffset - offset); + echoed = text.length > 0 && stripVTControlCharacters(fresh).replace(/\s+/gu, "").includes(text); + } + if (echoed && !/[\r\n]$/u.test(data)) return { delivered: true }; + if (echoed && (session.acceptedLifecycleState ?? session.metadata.status) === "idle" && Date.now() >= retryAt && submits < 3) { + if (!this.inputChecked(id, "\r", { acknowledgementRetry: true })) break; + submits++; + retryAt = Date.now() + 1000; + } + await poll(); + } + if (signal?.aborted) return canceled; + return { delivered: false, reason: "The CLI did not confirm accepting the prompt before the delivery deadline. Inspect the card before sending again; the text was not replayed." }; } private wakeLaunchWaiters(session: ManagedSession): void { for (const wake of [...session.launchWaiters]) wake(); } - inputChecked(id: string, data: string): boolean { + inputChecked(id: string, data: string, internal: { acknowledgementRetry?: boolean } = {}): boolean { + if (data !== "\x03") { try { this.inputGate?.(id); } catch { return false; } } if (typeof data !== "string" || data.length === 0) return false; const session = this.sessions.get(id); if (!session || session.metadata.exitCode !== null || !session.process) return false; + if(data!=="\x03" && this.isSessionBudgetPaused(session))return false; const process = session.process; + const mark = session.turnStarts ?? 0; + const pasteStart = data.lastIndexOf("\x1b[200~"), pasteEnd = data.lastIndexOf("\x1b[201~"); + if (!internal.acknowledgementRetry && (pasteStart >= 0 || pasteEnd >= 0)) session.inputBracketedPaste = pasteStart > pasteEnd; + const submitted = /[\r\n]$/u.test(data) && !session.inputBracketedPaste; + // All routes, including renderer and companion input, notify only after every no-write guard passed. + const afterWrite = internal.acknowledgementRetry ? [] + : [...(this.inputWriteObservers.get(id) ?? [])].map(observer => observer(data, submitted)); + if (!internal.acknowledgementRetry && submitted) { + if ((session.acceptedLifecycleState ?? session.metadata.status) !== "needs_approval") session.inputGeneration = (session.inputGeneration ?? 0) + 1; + session.promptTurnMark = mark; + delete session.answer; + } const written = tryPtyOperation(() => process.write(data)); + if (written && !internal.acknowledgementRetry) { + for (const callback of afterWrite) callback?.(); + } if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } @@ -1006,13 +1541,29 @@ export class TerminalManager { } /** `source` "hook" is the agent's own lifecycle hook (through the runtime gateway); "title" is its terminal title. */ - applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): void { + applyProviderSignal(id: string, signal: ProviderLifecycleSignal, source: "hook" | "title" = "hook"): boolean { const session = this.sessions.get(id); - if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + if (!session || session.metadata.status === "done" || session.metadata.status === "failed") return false; + let startsProviderTurn = false; + if (source === "hook" && session.metadata.provider === "opencode" && signal.requestId) { + const turns = session.providerTurnGenerations ??= new Map(); + const generation = session.inputGeneration ?? 0; + const known = turns.get(signal.requestId); + const starts = signal.event === "session.status:busy" || signal.event === "session.status:retry"; + if (known !== undefined && (known !== generation || session.providerTurnId !== signal.requestId)) return false; + if (known === undefined) { + if (!starts) return false; + startsProviderTurn = true; + turns.set(signal.requestId, generation); + session.providerTurnId = signal.requestId; + while (turns.size > 64) turns.delete(turns.keys().next().value!); + } + } if (source === "hook") session.hookSignals = (session.hookSignals ?? 0) + 1; const threadId = normalizeThreadId(session.metadata.provider, signal.threadId); const threadChanged = Boolean(threadId && threadId !== session.threadId); + if (threadId && this.resumedThreads.get(id) === null) this.resumedThreads.set(id, threadId); if (threadChanged) { session.threadId = threadId; this.schedulePersistence(); @@ -1020,24 +1571,46 @@ export class TerminalManager { const nextStatus = signal.state; // A turn starts when the agent moves to working; wait_for_agent compares it with the last delivered prompt. - if (nextStatus === "working" && session.metadata.status !== "working") session.turnStarts = (session.turnStarts ?? 0) + 1; + if (nextStatus === "working" && (startsProviderTurn || (session.acceptedLifecycleState ?? session.metadata.status) !== "working")) session.turnStarts = (session.turnStarts ?? 0) + 1; + session.acceptedLifecycleState = nextStatus; // A new turn: the previous answer is no longer this turn's. - if (nextStatus === "working") session.answer = undefined; + if (nextStatus === "working") { session.answer = undefined; session.answerTurnGeneration = session.inputGeneration ?? 0; } + // Result correlation remains active when the user disables lifecycle UI updates. + if (!this.lifecycleHooksEnabled) return true; const completed = nextStatus === "idle" && ["Stop", "StopFailure", "StopCancelled"].includes(signal.event ?? ""); const nextTurnCompleted = nextStatus === "working" ? false : completed || Boolean(session.metadata.turnCompleted); - if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return; + if (!threadChanged && session.metadata.status === nextStatus && Boolean(session.metadata.turnCompleted) === nextTurnCompleted) return true; session.metadata.status = nextStatus; session.metadata.turnCompleted = nextTurnCompleted; this.emitSession(session.metadata); + return true; + } + + /** Result transport completion is independent of the person's lifecycle UI preference. */ + resultLifecycleState(id: string): ProviderLifecycleSignal["state"] | null { + const session = this.sessions.get(id); + return session?.captureResult ? session.acceptedLifecycleState ?? null : null; + } + + /** Capture before starting an asynchronous answer read when the source has no provider turn id. */ + answerCaptureGeneration(id: string): number | null { + const session = this.sessions.get(id); + return session ? session.inputGeneration ?? 0 : null; } /** - * Keeps the final answer a result-capturing session reported with its turn's end (the Codex Stop hook, the OpenCode - * plugin's session.idle); read back masked by answer(). In memory only, never saved. + * Keeps a final answer only for its current turn/input generation. A no-hook source must pass the token captured + * before its asynchronous read; an uncorrelated answer cannot acknowledge a newly submitted prompt. + * In memory only, read back masked by answer(), never saved. */ - recordAnswer(id: string, result: { text: string; truncated: boolean }): void { + recordAnswer(id: string, result: { text: string; truncated: boolean }, correlation?: { turnId?: string | null; generation?: number }): void { const session = this.sessions.get(id); if (!session?.captureResult || typeof result?.text !== "string") return; + const generation = session.inputGeneration ?? 0; + if (correlation?.generation !== undefined && correlation.generation !== generation) return; + if (session.metadata.provider === "opencode" && correlation && "turnId" in correlation) { + if (!correlation.turnId || correlation.turnId !== session.providerTurnId || session.providerTurnGenerations?.get(correlation.turnId) !== generation) return; + } else if (session.promptTurnMark !== undefined && correlation?.generation === undefined && session.answerTurnGeneration !== generation) return; session.answer = { text: result.text, truncated: result.truncated === true, at: Date.now() }; } @@ -1114,6 +1687,37 @@ export class TerminalManager { } } + /** Serializes this card's run cleanup across exit/restart; callbacks retain their specific launch ownership. */ + private cleanupLaunchFiles(session: ManagedSession): Promise { + const cleanup = session.launchCleanup; + session.launchCleanup = null; + const previous = session.launchCleanupPending; + if (!cleanup) return previous ?? Promise.resolve(); + const pending = (async (): Promise => { + if (previous) await previous.catch(() => undefined); + await cleanup(); + })(); + session.launchCleanupPending = pending; + void pending.finally(() => { + if (session.launchCleanupPending === pending) delete session.launchCleanupPending; + }).catch(() => undefined); + return pending; + } + + /** Closing a card waits for producers of run files, not just the current process's adopted cleanup. */ + private async forgetLaunchFiles(session: ManagedSession): Promise { + while (session.launchTasks?.size) await Promise.allSettled([...session.launchTasks]); + try { await this.cleanupLaunchFiles(session); } + finally { + const errors: unknown[] = []; + // Different pipeline instances may share runsRoot; even their parent deletions must not overlap. + for (const pipeline of session.launchFilePipelines ?? []) { + try { await pipeline.forgetSession(session.metadata.id); } catch (error) { errors.push(error); } + } + if (errors.length) throw new AggregateError(errors, "Launch file folders could not be removed."); + } + } + /** * Closes a card. A card in a plugin environment releases it: `keepEnvironmentData` is the person's * answer to "Keep environment data?" (kept unless they said no). Quitting releases nothing. @@ -1131,7 +1735,13 @@ export class TerminalManager { this.flushOutput(id, session); this.sessions.delete(id); + this.inputWriteObservers.delete(id); + this.reviewDiffTracker.forget(id); + this.resumedThreads.delete(id); + session.reviewWorkspace?.cleanup(); + delete session.reviewWorkspace; this.isolationStarts.delete(id); + this.pendingIsolationReasons.delete(id); this.wakeLaunchWaiters(session); // Closed: an open report goes to the app; a pending audit runs and reports there. if (session.metadata.gitRisk) { @@ -1146,13 +1756,15 @@ export class TerminalManager { this.redaction.clear(`session:${id}`); this.releaseIsolation(id); session.launchToken += 1; - void session.launchCleanup?.().catch(() => undefined); - session.launchCleanup = null; - if (session.extras.options) void this.launchPipeline?.forgetSession(id).catch(() => undefined); + // launchToken and launch waiters were invalidated above: pending preparation/wrapping can now unwind before + // parent removal. Its original pipeline remains the owner even if the host configuration has since changed. + void this.forgetLaunchFiles(session).catch(() => undefined); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); session.agentOrchestration?.cleanup(); if (session.process) { + const resumed=this.processTreePause.resume(session.process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed before close.`,resumed.failed); try { session.process.kill(); } catch (error) { @@ -1162,7 +1774,7 @@ export class TerminalManager { const environment = session.extras.environment; if (environment && this.environments) { if (!this.quitting) { - void this.environments.release(environment, id, { keepData: options.keepEnvironmentData !== false, reason: "closed" }); + void this.environments.release(environment, id, { keepData: options.keepEnvironmentData ?? environment.kind!=="worktree", reason: "closed" }); } else if (this.sessionRestoreMode === "off") { // Nothing is saved, so the environment will not come back: stop its compute, keep its data. this.quitReleases.push(this.environments.release(environment, id, { @@ -1201,6 +1813,7 @@ export class TerminalManager { size: descriptor.size, role: descriptor.role, ...(descriptor.parentSessionId !== undefined ? { parentSessionId: descriptor.parentSessionId } : {}), + ...(descriptor.taskScope ? {taskScope:{...descriptor.taskScope}} : {}), status: initialSessionStatus(descriptor.provider), startedAt: Date.now(), exitCode: null, @@ -1208,7 +1821,8 @@ export class TerminalManager { ...(step.note ? { restoreNote: step.note } : {}), ...(descriptor.environment ? { environment: environmentBadge(descriptor.environment) } : {}), ...(descriptor.model !== undefined ? { model: descriptor.model } : {}), - ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}) + ...(descriptor.effort !== undefined ? { effort: descriptor.effort } : {}), + ...(descriptor.reviewRequested !== undefined ? {reviewRequested:descriptor.reviewRequested} : {}) }; const extras: PersistedSessionExtras = { ...(descriptor.options ? { options: descriptor.options } : {}), @@ -1268,9 +1882,13 @@ export class TerminalManager { } } const resume: ResumeRequest = step.launch === "stopped" ? null : step.launch; + const restoreTaskRootId=descriptor.taskScope?.id + ?? (descriptor.parentSessionId && this.sessions.has(descriptor.parentSessionId) + ? this.taskScopeFor(descriptor.parentSessionId).id + : descriptor.id); + const deferForBudget=this.budgetPausedTaskRoots.has(restoreTaskRootId); const awaitMeasuredGrid = directoryReady - && descriptor.provider === "grok" - && this.providerClis.get(descriptor.provider).state === "available"; + && (deferForBudget || descriptor.provider === "grok" && this.providerClis.get(descriptor.provider).state === "available"); const contributed = directoryReady && !awaitMeasuredGrid && (Boolean(extras.options) || Boolean(extras.environment) || this.policyApplies(descriptor.provider)); @@ -1405,10 +2023,16 @@ export class TerminalManager { private adoptIsolationStart(id: string, session: ManagedSession): void { const started = this.isolationStarts.get(id); - if (started === undefined) return; - this.isolationStarts.delete(id); - session.extras.gitAuditSince ??= started; - this.schedulePersistence(); + const reason = this.pendingIsolationReasons.get(id); + if (started !== undefined) { + this.isolationStarts.delete(id); + session.extras.gitAuditSince ??= started; + } + if (reason !== undefined) { + this.pendingIsolationReasons.delete(id); + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason }; + } + if (started !== undefined || reason !== undefined) this.schedulePersistence(); } /** The person's answer to a git risk report: neutralize removes what was found; keep leaves it. Both close it. */ @@ -1432,7 +2056,8 @@ export class TerminalManager { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) + [...this.sessions.values()].filter((session) => !session.reviewWorkspace) + .map((session) => persistedTerminalSession(session.metadata, session.threadId, session.extras)) ); } @@ -1470,7 +2095,12 @@ export class TerminalManager { private launchAwaitingSession(id: string, session: ManagedSession): void { if (!session.awaitingInitialResize) return; + if(this.isSessionBudgetPaused(session)) { + session.budgetDeferredLaunch=true; + return; + } session.awaitingInitialResize = false; + session.budgetDeferredLaunch=false; const resume = session.resumeOnLaunch; session.resumeOnLaunch = null; if (this.contributed(session)) { @@ -1531,7 +2161,10 @@ export class TerminalManager { role: SessionRole = "agent", answerCaptureGrantExpiresAt?: number, contribution: LaunchContribution | null = null, - parentSessionId?: string + parentSessionId?: string, + taskProjectRoot?: string, + reviewerControl?: ReadOnlyReviewerLaunch, + captureReviewDiff = this.sessions.get(id)?.captureReviewDiff === true ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -1540,11 +2173,15 @@ export class TerminalManager { failure: LaunchFailure | null; } { const none = { process: null, agentBrowser: null, agentRuntime: null, agentOrchestration: null }; - const decision = this.launchIsolation(id, provider, profile, role, null); + const projectRoot = taskProjectRoot ?? this.taskProjectRoot(cwd, parentSessionId); + const decision = this.launchIsolation(id, provider, profile, role, null, projectRoot); if (decision.refuse) return { ...none, failure: { diagnostic: `Launch refused: ${decision.refuse}`, exitCode: 1 } }; + if (reviewerControl && (!decision.apply || decision.profile !== "plan")) { + return { ...none, failure: { diagnostic: "A diff-only reviewer requires the operating-system isolation layer and a read-only Plan profile. The reviewer was not started.", exitCode: 1 } }; + } profile = decision.profile; const planned = this.planSpawn(id, provider, profile, cwd, resume, captureResult, role, answerCaptureGrantExpiresAt, contribution, - this.personTrustedFolder(parentSessionId, cwd), false, decision.apply); + reviewerControl ? undefined : this.personTrustedFolder(parentSessionId, cwd), false, decision.apply, Boolean(reviewerControl)); if ("failure" in planned) { return { ...none, failure: planned.failure }; } @@ -1552,13 +2189,14 @@ export class TerminalManager { let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { - spawn = this.wrapIsolated(id, provider, profile, planned); + spawn = this.wrapIsolated(id, provider, profile, planned, projectRoot, contribution?.accountHome, reviewerControl?.deniedReadPaths); } catch (error) { planned.cleanup(); return { ...none, failure: { diagnostic: `Launch refused: ${error instanceof Error ? error.message : String(error)}`, exitCode: 1 } }; } } try { + this.reviewDiffTracker.beforeSpawn(id, planned.cwd, captureReviewDiff, this.reviewParentDirectory(parentSessionId)); const process = this.spawnPty(spawn.command, spawn.args, { name: "xterm-256color", cols, rows, cwd: planned.cwd, env: spawn.env }); @@ -1571,12 +2209,26 @@ export class TerminalManager { failure: null }; } catch (error) { + this.reviewDiffTracker.forget(id); planned.cleanup(); this.releaseIsolation(id); throw error; } } + private reviewParentDirectory(parentSessionId?: string): string | undefined { + if (!parentSessionId) return undefined; + return this.launchContexts.get(parentSessionId)?.cwd ?? this.sessions.get(parentSessionId)?.metadata.cwd; + } + + /** Only the launch-owned baseline can attribute a diff to this worker. */ + async readReviewDiff(id: string): Promise { + const baseline = this.reviewDiffTracker.baseline(id); + const diff = await new GitCheckpoints(text => this.redactSecrets(text)).workingDiff(baseline.root, baseline.head); + this.reviewDiffTracker.assertCurrent(id, baseline); + return diff; + } + /** In the manual profile the CLI's own configuration decides: the card says when it skips approvals. */ private noteConfiguredMode(id: string, provider: ProviderId, profile: LaunchProfile, env: Record, cwd: string): void { const found = profile === "normal" && provider !== "terminal" ? configuredMode(provider, env, cwd) : null; @@ -1594,9 +2246,24 @@ export class TerminalManager { return role === "subagent" || Boolean(this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)); } - private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null): IsolationDecision { + /** Resolve the original task project for account and reviewer launches. */ + private taskProjectRoot(cwd: string, parentSessionId?: string): string { + let current = parentSessionId ? this.sessions.get(parentSessionId) : undefined; + let root: ManagedSession | undefined; + const visited = new Set(); + while (current && !visited.has(current.metadata.id)) { + visited.add(current.metadata.id); + root = current; + current = current.metadata.parentSessionId ? this.sessions.get(current.metadata.parentSessionId) : undefined; + } + return root?.metadata.taskScope?.cwd ?? root?.metadata.cwd ?? cwd; + } + + private decideIsolation(provider: ProviderId, profile: LaunchProfile, delegated: boolean, environment: { isolated: boolean; label: string } | null, + cwd?: string): IsolationDecision { if (!this.isolation) return { apply: false, profile }; - return this.isolation.decide({ provider, profile, delegated, environment }); + const decision = this.isolation.decide({ provider, profile, delegated, environment, cwd }); + return decision; } /** @@ -1604,9 +2271,10 @@ export class TerminalManager { * and a subagent whose layer is gone runs in normal from now on. */ private launchIsolation(id: string, provider: ProviderId, profile: LaunchProfile, role: SessionRole, - environment: { isolated: boolean; label: string } | null): IsolationDecision { - const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment); + environment: { isolated: boolean; label: string } | null, projectRoot?: string): IsolationDecision { const metadata = this.sessions.get(id)?.metadata; + const taskProjectRoot = projectRoot ?? metadata?.taskScope?.cwd ?? this.taskProjectRoot(metadata?.cwd ?? "", metadata?.parentSessionId); + const decision = this.decideIsolation(provider, profile, this.isDelegated(id, role), environment, taskProjectRoot); if (metadata && !decision.refuse) { if (decision.isolation) metadata.isolation = decision.isolation; else delete metadata.isolation; @@ -1616,7 +2284,9 @@ export class TerminalManager { } /** Wraps a planned launch in the isolation layer (throws when the layer cannot start: the launch is refused). */ - private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, planned: { command: string; args: string[] | string; cwd: string; env: Record }): { command: string; args: string[]; env: Record } { + private wrapIsolated(id: string, provider: ProviderId, profile: LaunchProfile, + planned: { command: string; args: string[] | string; cwd: string; env: Record }, taskProjectRoot: string, + accountHome?: string, deniedReadPaths?: readonly string[]): { command: string; args: string[]; env: Record } { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); const grant = controlGrantFolder(planned.env); @@ -1628,10 +2298,24 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(deniedReadPaths ? { restrictHomeReads:true, runtimeReadable:[planned.command, + ...(this.agentRuntime?.readableRuntimePaths?.() ?? [])] } : {}), + ...(accountHome ? { accountHome } : {}), ...(grant ? { grantedPrivate: [grant] } : {}) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); + if (wrapped.isolationReason) { + const session = this.sessions.get(id); + if (session) { + if (session.metadata.isolation) session.metadata.isolation = { ...session.metadata.isolation, reason: wrapped.isolationReason }; + this.schedulePersistence(); + this.emitSession(session.metadata); + } else { + this.pendingIsolationReasons.set(id, wrapped.isolationReason); + } + } // Its repositories are audited once it ends (and on close or restore): from the earliest session not audited yet. // A first launch wraps before its card exists: adoptIsolationStart records it then. const session = this.sessions.get(id); @@ -1666,33 +2350,37 @@ export class TerminalManager { contribution: LaunchContribution | null, trustedFolder?: string, environmentWrapped = false, - isolated = false + isolated = false, + reviewOnly = false ): PlannedSpawn | { failure: LaunchFailure } { const providerCli = provider === "terminal" ? undefined : this.providerClis.get(provider); if (providerCli?.state === "unavailable") return { failure: providerCli }; + if (resume === null || provider === "terminal") this.resumedThreads.delete(id); + else this.resumedThreads.set(id, typeof resume === "object" ? resume.threadId : null); // What decides whether Claude's lifecycle hooks may go over HTTP (ClaudeHttpHooks.ts): where and how it runs. - const claudeHttp = provider === "claude" && providerCli?.state === "available" ? { + const claudeHttp = !reviewOnly && provider === "claude" && providerCli?.state === "available" ? { executable: providerCli.executable, profile, environmentWrapped, env: { ...terminalEnvironment(), ...providerCli.environment, ...(contribution?.env ?? {}) }, args: contribution?.args ?? [], - cwd + cwd, } : undefined; const agentRuntime = provider === "terminal" ? null : this.agentRuntime?.prepareLaunch({ terminalSessionId: id, provider, cwd, - ...(captureResult ? { captureResult: true } : {}), + ...(captureResult || reviewOnly ? { captureResult: true } : {}), + ...(reviewOnly ? { decisions: false } : {}), ...(claudeHttp ? { claudeHttp } : {}), ...(answerCaptureGrantExpiresAt === undefined ? {} : { answerCaptureGrantExpiresAt }) }) ?? null; let pluginTools: string[] = []; try { - pluginTools = provider === "terminal" ? [] : this.pluginToolNames(role, provider); + pluginTools = provider === "terminal" || reviewOnly ? [] : this.pluginToolNames(role, provider); } catch { // Plugins never block a launch; the session simply gets no plugin tools. } const bridged = role === "orchestrator" || pluginTools.length > 0; - const agentOrchestration = bridged && this.agentOrchestration?.isEnabled + const agentOrchestration = !reviewOnly && bridged && this.agentOrchestration?.isEnabled ? this.agentOrchestration.prepareLaunch({ terminalSessionId: id }) : null; let agentBrowser: PreparedAgentBrowserPtyLaunch | null = null; @@ -1708,7 +2396,7 @@ export class TerminalManager { // and minimax until its MCP configuration is wired (plain PTY for now). // devin is cloud-session oriented and takes no browser adapter yet, // and antigravity keeps plain PTY integration for the same reason. - agentBrowser = provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" + agentBrowser = reviewOnly || provider === "terminal" || provider === "grok" || provider === "omp" || provider === "pi" || provider === "cursor" || provider === "minimax" || provider === "devin" || provider === "antigravity" ? null : this.agentBrowser?.prepareLaunch({ terminalSessionId: id, @@ -1729,7 +2417,7 @@ export class TerminalManager { : { ...browserEnvironment, ...runtimeEnvironment }), ...orchestrationEnvironment, // Orchestrators alone learn where the control descriptor and CLI are. - ...controlEnvironment(role, this.controlConnection, id) + ...(!reviewOnly ? controlEnvironment(role, this.controlConnection, id) : {}) }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); @@ -1850,7 +2538,9 @@ export class TerminalManager { ): void { const token = ++session.launchToken; const { metadata } = session; - void this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt) + const tasks = session.launchTasks ??= new Set(); + // Register before invoking plugin code, which may synchronously close or restart its card. + const task = Promise.resolve().then(() => this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt)) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -1864,7 +2554,9 @@ export class TerminalManager { this.emitSession(metadata, outcome === "failed" ? failureOrigin : null); this.schedulePersistence(); }) - .finally(() => this.wakeLaunchWaiters(session)); + .finally(() => { tasks.delete(task); this.wakeLaunchWaiters(session); }); + tasks.add(task); + void task.catch(() => undefined); } private async runContributedLaunch( @@ -1877,6 +2569,12 @@ export class TerminalManager { ): Promise { const { metadata } = session; const live = (): boolean => this.sessions.get(id) === session && session.launchToken === token && !session.process; + const waitIfBudgetPaused = async (): Promise => { + while(this.isSessionBudgetPaused(session)) { + if(!await this.awaitBudgetResume(session))return false; + } + return live(); + }; const refuse = (reason: string): LaunchOutcome => { // "Launch refused" leads, so the failure summary quotes the reason as the cause. metadata.failureDetails = this.redactSecrets(`Launch refused: ${reason}`); @@ -1884,6 +2582,9 @@ export class TerminalManager { }; const environments = this.environments; + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + if(!live())return "superseded"; + // 1. Place a new session where the person chose. The choice stays saved with the card until the plugin has // prepared it, so a card whose preparation was cut short (quit, crash) or failed never restores locally. const choice = session.extras.environmentChoice; @@ -1896,6 +2597,14 @@ export class TerminalManager { if (placed.ok) void environments.release(placed.environment, id, { keepData: false, reason: "closed" }); return "superseded"; } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } + if(!live()) { + if(placed.ok)void environments.release(placed.environment,id,{keepData:false,reason:"closed"}); + return "superseded"; + } if (!placed.ok) return refuse(placed.reason); delete session.extras.environmentChoice; session.environmentReady = true; @@ -1918,148 +2627,164 @@ export class TerminalManager { } const resumed = await environments.resume(environment, id); if (!live()) return "superseded"; + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused())return "superseded"; + if(!live())return "superseded"; if (!resumed.ok) return refuse(`environment stopped: ${resumed.reason}`); session.environmentReady = true; } // 3. Chosen launch contributors, and the launch policies that apply. let contribution: LaunchContribution | null = null; - const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); - if (session.extras.options || this.policyApplies(metadata.provider)) { - const pipeline = this.launchPipeline; - if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); - const placedIn = session.extras.environment; - const prepared = await pipeline.prepare({ - sessionId: id, - provider: metadata.provider, - profile: metadata.profile, - role: metadata.role, - cwd: metadata.cwd, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), - restoring, - resume: resume !== null, - options: structuredClone(session.extras.options ?? {}) as Record>, - environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, - ...(trustedFolder ? { trustedFolder } : {}) - }); - if (!live()) { - if (prepared.ok) void prepared.cleanup().catch(() => undefined); - return "superseded"; + let adopted = false; + let plannedCleanup: (() => void) | undefined; + try { + const trustedFolder = session.extras.environment ? undefined : this.personTrustedFolder(metadata.parentSessionId, metadata.cwd); + if (session.extras.options || this.policyApplies(metadata.provider)) { + const pipeline = this.launchPipeline; + if (!pipeline) return refuse(missingLaunchPlugins(Object.keys(session.extras.options ?? {}))); + (session.launchFilePipelines ??= new Set()).add(pipeline); + const placedIn = session.extras.environment; + const prepared = await pipeline.prepare({ + sessionId: id, + provider: metadata.provider, + profile: metadata.profile, + role: metadata.role, + cwd: metadata.cwd, + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + restoring, + resume: resume !== null, + options: structuredClone(session.extras.options ?? {}) as Record>, + environment: placedIn ? { pluginId: placedIn.pluginId, kind: placedIn.kind } : null, + ...(trustedFolder ? { trustedFolder } : {}) + }); + if (prepared.ok) contribution = prepared; + if (!live()) return "superseded"; + if (this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) return "superseded"; + if (!live()) return "superseded"; + if (!prepared.ok) return refuse(prepared.reason); + session.accountId=prepared.accountId ?? selectedAccountId(session.extras.options); + session.accountHome=prepared.accountHome; + this.addLaunchSecrets(session, prepared.secrets); } - if (!prepared.ok) return refuse(prepared.reason); - contribution = prepared; - this.addLaunchSecrets(session, prepared.secrets); - } - const dropContribution = (): void => { - void contribution?.cleanup().catch(() => undefined); - }; - // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. - const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; - if (environment && keeps.launch !== true && metadata.profile !== "normal") { - dropContribution(); - return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); - } - const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, - environment ? { isolated: keeps.isolated === true, label: environment.label } : null); - if (decision.refuse) { - dropContribution(); - return refuse(decision.refuse); - } - if (environment && keeps.launch !== true) { - metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), - reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; - } + // 4. What the environment keeps of CanvasTTY's protection, and the isolation layer for this launch. + const keeps = environment ? this.environments?.keeps?.(environment) ?? {} : {}; + if (environment && keeps.launch !== true && metadata.profile !== "normal") { + return refuse(`${environment.label} does not pass the launch on unchanged (the plugin does not declare it), so the ${metadata.profile} profile's settings and CanvasTTY's hooks would not reach the agent there. Launch it in normal, or use an environment that keeps them.`); + } + const taskProjectRoot = metadata.taskScope?.cwd ?? this.taskProjectRoot(metadata.cwd, metadata.parentSessionId); + const decision = this.launchIsolation(id, metadata.provider, metadata.profile, metadata.role, + environment ? { isolated: keeps.isolated === true, label: environment.label } : null, taskProjectRoot); + if (decision.refuse) { + return refuse(decision.refuse); + } + if (environment && keeps.launch !== true) { + metadata.isolation = { ...(metadata.isolation ?? { state: "environment" }), + reason: `${metadata.isolation?.reason ? `${metadata.isolation.reason} ` : ""}Base protection and CanvasTTY's hooks do not reach the agent in ${environment.label}.` }; + } - // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. - let planned: PlannedSpawn | { failure: LaunchFailure }; - try { - planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, - session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); - } catch (error) { - dropContribution(); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - if ("failure" in planned) { - dropContribution(); - applyLaunchFailure(metadata, planned.failure); - return "failed"; - } - const abandon = (): void => { - planned.cleanup(); - dropContribution(); - }; - this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); - let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; - if (environment && environments) { - if (typeof planned.args === "string") { - abandon(); - return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + // 5. The host spawns the PTY; an environment only rewrites what is spawned, and the isolation layer wraps that. + let planned: PlannedSpawn | { failure: LaunchFailure }; + try { + planned = this.planSpawn(id, metadata.provider, decision.profile, metadata.cwd, resume, + session.captureResult, metadata.role, answerCaptureGrantExpiresAt, contribution, trustedFolder, Boolean(environment), decision.apply); + } catch (error) { + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; } - const secretValues = new Set(contribution?.secrets ?? []); - const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); - // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. - const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) - .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); - const wrapped = await environments.wrap(environment, { - sessionId: id, - provider: metadata.provider, - launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, - secretEnvNames, - takenEnv: new Set(Object.keys(planned.launchEnvironment)), - path: launchSearchPath(planned.env) - }); - if (!live()) { - abandon(); + if ("failure" in planned) { + applyLaunchFailure(metadata, planned.failure); + return "failed"; + } + plannedCleanup = planned.cleanup; + this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); + let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; + if (environment && environments) { + if (typeof planned.args === "string") { + return refuse("this provider's Windows batch launcher cannot run in a plugin environment."); + } + const secretValues = new Set(contribution?.secrets ?? []); + const secretEnvNames = Object.keys(contribution?.env ?? {}).filter((key) => secretValues.has(contribution!.env[key]!)); + // The environment sees the launch's own variables, never CanvasTTY's reserved ones or secret values. + const visible = Object.fromEntries(Object.entries(planned.launchEnvironment) + .filter(([key]) => !RESERVED_ENV.test(key) && !secretEnvNames.includes(key))); + const wrapped = await environments.wrap(environment, { + sessionId: id, + provider: metadata.provider, + launch: { command: planned.command, args: planned.args, env: visible, cwd: planned.cwd }, + secretEnvNames, + takenEnv: new Set(Object.keys(planned.launchEnvironment)), + path: launchSearchPath(planned.env) + }); + if (!live()) { + return "superseded"; + } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { + return "superseded"; + } + if(!live()) { + return "superseded"; + } + if (!wrapped.ok) { + return refuse(wrapped.reason); + } + this.addLaunchSecrets(session, wrapped.secrets); + spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, + env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; + } + if (decision.apply) { + try { + spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn, taskProjectRoot, contribution?.accountHome, undefined), cwd: spawn.cwd }; + } catch (error) { + return refuse(error instanceof Error ? error.message : String(error)); + } + } + if(this.isSessionBudgetPaused(session) && !await waitIfBudgetPaused()) { return "superseded"; } - if (!wrapped.ok) { - abandon(); - return refuse(wrapped.reason); + if(!live()) { + return "superseded"; } - this.addLaunchSecrets(session, wrapped.secrets); - spawn = { command: wrapped.command, args: wrapped.args, cwd: wrapped.cwd, - env: { ...planned.env, ...(wrapped.cwd !== planned.cwd ? { PWD: wrapped.cwd } : {}), ...wrapped.env } }; - } - if (decision.apply) { + let process: IPty; try { - spawn = { ...this.wrapIsolated(id, metadata.provider, decision.profile, spawn), cwd: spawn.cwd }; + this.retryLaunchValidators.get(id)?.(); + this.reviewDiffTracker.beforeSpawn(id, spawn.cwd, session.captureReviewDiff === true, this.reviewParentDirectory(metadata.parentSessionId)); + process = this.spawnPty(spawn.command, spawn.args, { + name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env + }); } catch (error) { - abandon(); - return refuse(error instanceof Error ? error.message : String(error)); + this.reviewDiffTracker.forget(id); + this.releaseIsolation(id); + metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); + return "failed"; + } + session.process = process; + if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { + metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); + } else { + delete metadata.nativeEditor; + } + this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); + session.agentBrowser = planned.agentBrowser; + session.agentRuntime = planned.agentRuntime; + session.agentOrchestration = planned.agentOrchestration; + session.launchCleanup = contribution?.cleanup ?? null; + adopted = true; + metadata.status = initialSessionStatus(metadata.provider); + metadata.exitCode = null; + metadata.failureDetails = null; + this.bindProcess(id, session, process); + const runtimeStatus = this.agentRuntime?.currentStatus(id); + if (runtimeStatus) metadata.status = runtimeStatus; + if (environment) this.describeEnvironment(id, session, environment); + return "launched"; + } finally { + // Unadopted resources remain owned by this tracked launch, including throws during plugin wrapping. + if (!adopted) { + try { plannedCleanup?.(); } + finally { await contribution?.cleanup().catch(() => undefined); } } } - let process: IPty; - try { - process = this.spawnPty(spawn.command, spawn.args, { - name: "xterm-256color", cols: session.cols, rows: session.rows, cwd: spawn.cwd, env: spawn.env - }); - } catch (error) { - abandon(); - this.releaseIsolation(id); - metadata.failureDetails = this.redactSecrets(error instanceof Error ? error.message : String(error)); - return "failed"; - } - session.process = process; - if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { - metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); - } else { - delete metadata.nativeEditor; - } - this.launchContexts.set(id, { cwd: spawn.cwd, configDir: spawn.env.CLAUDE_CONFIG_DIR ?? null }); - session.agentBrowser = planned.agentBrowser; - session.agentRuntime = planned.agentRuntime; - session.agentOrchestration = planned.agentOrchestration; - session.launchCleanup = contribution?.cleanup ?? null; - metadata.status = initialSessionStatus(metadata.provider); - metadata.exitCode = null; - metadata.failureDetails = null; - this.bindProcess(id, session, process); - const runtimeStatus = this.agentRuntime?.currentStatus(id); - if (runtimeStatus) metadata.status = runtimeStatus; - if (environment) this.describeEnvironment(id, session, environment); - return "launched"; } private addLaunchSecrets(session: ManagedSession, secrets: readonly string[]): void { @@ -2083,6 +2808,16 @@ export class TerminalManager { const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; + // OpenCode creates its first conversation only after submission, so a fresh home screen has no + // session.created hook yet. Its rendered prompt and command hints are the startup readiness signal. + if (current.metadata.provider === "opencode" && !current.cliInputReady) { + current.readinessOutput = ((current.readinessOutput ?? "") + data).slice(-16_000); + const screen = stripVTControlCharacters(current.readinessOutput); + if (/Ask anything[….]/u.test(screen) && /ctrl\+p\s*commands/u.test(screen)) { + current.cliInputReady = true; + delete current.readinessOutput; + } + } const lifecycleState = current.lifecycle?.push(data); if (lifecycleState) current.titleState = lifecycleState; if (lifecycleState && !titleDefersToHooks(current, lifecycleState)) { @@ -2096,37 +2831,48 @@ export class TerminalManager { let exited!: () => void; this.liveProcesses.set(process, new Promise((resolve) => { exited = resolve; })); - process.onExit(({ exitCode }) => { + process.onExit(({ exitCode, signal }) => { this.liveProcesses.delete(process); exited(); const current = this.sessions.get(id); if (!current || current !== session || current.process !== process) return; // node-pty calls this from a native callback that aborts the whole app when JavaScript throws in it. try { - this.recordExit(id, current, exitCode); + const resumed=this.processTreePause.resume(process); + if(resumed.failed)console.warn(`Budget-paused PTY ${id} could not be resumed after exit.`,resumed.failed); + this.recordExit(id, current, exitCode, signal); } catch (error) { console.warn(`PTY ${id} exit could not be recorded.`, error); } }); } - private recordExit(id: string, current: ManagedSession, exitCode: number): void { + private recordExit(id: string, current: ManagedSession, reportedExitCode: number, signal?: number): void { + this.reviewDiffTracker.stopped(id); + current.process = null; this.flushOutput(id, current); this.releaseIsolation(id); + current.reviewWorkspace?.cleanup(); if (current.extras.gitAuditSince !== undefined) void this.auditGit(id, current); + // node-pty reports a process killed by a signal as exitCode 0 plus the signal: that is a crash, not a clean finish + // (an OpenCode killed with SIGKILL showed "done, exit 0" and could not be retried). Shells report it as 128+signal. + const killedBy = typeof signal === "number" && signal > 0 ? signal : 0; + const exitCode = killedBy ? 128 + killedBy : reportedExitCode; current.metadata.exitCode = exitCode; current.metadata.status = exitCode === 0 ? "done" : "failed"; - current.metadata.failureDetails = exitCode === 0 + const details = exitCode === 0 ? null : terminalFailureDetails(this.redactSecrets(current.bufferChunks.slice(current.bufferStart).join(""))); + current.metadata.failureDetails = killedBy + ? `${signalExitText(killedBy)}${details ? `\n${details}` : ""}`.slice(0, 8_000) + : details; current.agentBrowser?.cleanup(); current.agentBrowser = null; current.agentRuntime?.cleanup(); current.agentRuntime = null; current.agentOrchestration?.cleanup(); current.agentOrchestration = null; - void current.launchCleanup?.().catch(() => undefined); - current.launchCleanup = null; + void this.cleanupLaunchFiles(current).catch(() => undefined); this.emitSession(current.metadata); // Recorded at the moment of exit, so a finished agent is never relaunched. this.schedulePersistence(); @@ -2228,7 +2974,15 @@ function resetLaunchSignals(session: ManagedSession): void { delete session.answer; delete session.turnStarts; delete session.promptTurnMark; + session.inputGeneration = (session.inputGeneration ?? 0) + 1; + delete session.acceptedLifecycleState; + delete session.answerTurnGeneration; + delete session.providerTurnId; + delete session.providerTurnGenerations; + delete session.inputBracketedPaste; delete session.hookSignals; + delete session.cliInputReady; + delete session.readinessOutput; delete session.titleState; if (session.answeredPromptTimer) clearTimeout(session.answeredPromptTimer); session.answeredPromptTimer = undefined; @@ -2395,6 +3149,11 @@ function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } +function canonicalProjectPath(path: string): string { + try { return realpathSync(path).normalize("NFC"); } + catch { return onDiskPath(path).normalize("NFC"); } +} + function publicSessionMetadata(session: ManagedSession): SessionMetadata { const metadata = structuredClone(session.metadata); if (session.threadId) metadata.threadId = session.threadId; @@ -2452,3 +3211,9 @@ function appendScrollback(session: ManagedSession, data: string): void { session.bufferStart = 0; } } + +/** "Killed by signal SIGKILL (9)." from node-pty's signal number. */ +function signalExitText(signal: number): string { + const name = Object.entries(osConstants.signals).find(([, value]) => value === signal)?.[0]; + return `The process was killed by signal ${name ? `${name} (${signal})` : signal}.`; +} diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 269c49bd8..569c75745 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -25,6 +25,7 @@ export const MAX_PLUGIN_SLOT_BYTES = 4_096; const MAX_OPTION_PLUGINS = 16; export interface PersistedTerminalSession { + taskScope?:{id:string;cwd:string;startedAt:number}; id: string; provider: ProviderId; profile: LaunchProfileId; @@ -57,6 +58,7 @@ export interface PersistedTerminalSession { /** The model and reasoning effort its launches ask the CLI for (launchModel.ts). */ model?: string; effort?: ReasoningEffort; + reviewRequested?: boolean; /** An isolated session ran since then and its repositories were not audited yet, or a report is still open. */ gitAuditSince?: number; } @@ -242,6 +244,7 @@ export function persistedTerminalSession( position: { ...metadata.position }, size: { ...metadata.size }, ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(metadata.taskScope ? {taskScope:{...metadata.taskScope}} : {}), ...(normalizedThreadId !== undefined ? { threadId: normalizedThreadId } : {}), lastState, ...(lastState !== "running" ? { exitCode: metadata.exitCode } : {}), @@ -252,7 +255,8 @@ export function persistedTerminalSession( ...(extras.ownerPluginId ? { ownerPluginId: extras.ownerPluginId } : {}), ...(extras.gitAuditSince !== undefined ? { gitAuditSince: extras.gitAuditSince } : {}), ...(metadata.model !== undefined ? { model: metadata.model } : {}), - ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}) + ...(metadata.effort !== undefined ? { effort: metadata.effort } : {}), + ...(metadata.reviewRequested !== undefined ? { reviewRequested: metadata.reviewRequested } : {}) }; } @@ -322,6 +326,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste height: clamp(session.size.height, 260, 1_100) }, ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(isTaskScope(session.taskScope) ? {taskScope:{...session.taskScope}} : {}), ...(threadId !== undefined ? { threadId } : {}), lastState, ...(lastState !== "running" ? { exitCode } : {}), @@ -336,7 +341,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste ...(session.provider !== "terminal" && session.model !== undefined && launchModelProblem(session.provider as ProviderId, session.model) === null ? { model: session.model } : {}), ...(session.provider !== "terminal" && session.effort !== undefined && launchEffortProblem(session.provider as ProviderId, session.effort) === null - ? { effort: session.effort } : {}) + ? { effort: session.effort } : {}), + ...(typeof session.reviewRequested === "boolean" ? { reviewRequested: session.reviewRequested } : {}) }); ids.add(session.id); } @@ -427,3 +433,7 @@ function isReadableState(value: unknown): boolean { function isMissingFile(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } + +function isTaskScope(value:unknown):value is {id:string;cwd:string;startedAt:number} { + return isRecord(value) && typeof value.id==="string" && /^[\w-]{1,160}$/.test(value.id) && typeof value.cwd==="string" && value.cwd.length>0 && value.cwd.length<=4096 && typeof value.startedAt==="number" && Number.isFinite(value.startedAt) && value.startedAt>0; +} diff --git a/src/main/services/TimelineIndexScan.ts b/src/main/services/TimelineIndexScan.ts new file mode 100644 index 000000000..7fb055dd8 --- /dev/null +++ b/src/main/services/TimelineIndexScan.ts @@ -0,0 +1,258 @@ +import { createReadStream } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { addTimelineSearchEvent, createTimelineSegmentSearchIndex, type TimelineSegmentSearchIndex } from "./TimelineSearchIndex.ts"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +export interface TimelineUsageContext { provider?: string; model?: string; accountId?: string; taskId?: string } +export type TimelineIndexedUsage = TimelineUsageContext & { + input: number | null; + output: number | null; + total?: number; + cost: number | null; + source: string; + cumulative?: boolean; + counterId?: string; + /** A resumed conversation's first sample: its counter already held usage from earlier runs. */ + baseline?: boolean; +}; +type Usage=TimelineIndexedUsage; +export interface BudgetUsageContribution { + sessionId:string; + identity:string; + taskId?:string; + provider?:string; + model?:string; + mode:"reported"|"priced"|"covered"|"reported-base"; + tokens:number; + input:number|null; + output:number|null; + reportedCostUsd:number|null; +} +export interface BudgetCounterState {current:TimelineIndexedUsage;taskOwner:string|null;mixedOwners:boolean;reportedCostSeen:boolean; + /** Started from a resumed conversation's baseline: its total so far, cost included, predates this run. */ + fromBaseline?:boolean} +export interface TimelineTaskSessionIndex { + id:string; + taskId:string; + title:string; + types:Map; + lastSegment:string; +} + +export interface TimelineDirectoryIndex { + lastSegmentAt:number; + usage:Map; + budgetUsage:Map; + budgetCounters:Map; + segments:Map; + taskSessions:Map>; +} + +const MAX_EVENT_LINE_CHARS = 64 * 1024; +export const MAX_INDEXED_SEGMENTS = 256; +const NAME_PATTERN = /^\d{16}-[\w-]+\.ndjson$/u; +export async function scanTimelineDirectory(directory: string): Promise { + const names = (await readdir(directory)).filter((name: string) => NAME_PATTERN.test(name)).sort(); + const usage = new Map(); + const budgetUsage=new Map(); + const budgetCounters=new Map(); + const taskSessions=new Map>(); + const segments=new Map(); + let lastSegmentAt = 0; + for (const name of names) { + lastSegmentAt = Math.max(lastSegmentAt, Number(name.slice(0, 16)) || 0); + const metadata = await stat(join(directory, name)); + const index = createTimelineSegmentSearchIndex(metadata.size, metadata.mtimeMs); + for await (const line of boundedLines(join(directory, name))) { + if (line === null) { index.complete = false; continue; } + if (!line) continue; + let event: any; + try { event = JSON.parse(line); } catch { continue; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") continue; + addTimelineSearchEvent(index, event); + indexTimelineTaskSession(taskSessions,event,name); + const usageUpdate = nextTimelineUsage(usage, event); + if (usageUpdate) usage.set(usageUpdate.key, usageUpdate.value); + applyTimelineBudgetUsageEvent(budgetUsage,budgetCounters,event); + } + segments.set(name,index); + if (segments.size > MAX_INDEXED_SEGMENTS) segments.delete(segments.keys().next().value!); + } + return {lastSegmentAt,usage,budgetUsage,budgetCounters,segments,taskSessions}; +} + +async function* boundedLines(path: string): AsyncGenerator { + let pending = ""; + let oversized = false; + for await (const chunk of createReadStream(path, { encoding: "utf8" })) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + if (!oversized) { + const part = chunk.slice(from, end); + if (pending.length + part.length > MAX_EVENT_LINE_CHARS) { pending = ""; oversized = true; } + else pending += part; + } + if (newline < 0) break; + yield oversized ? null : pending.endsWith("\r") ? pending.slice(0, -1) : pending; + pending = ""; + oversized = false; + from = newline + 1; + } + } + if (oversized) yield null; + else if (pending) yield pending; +} + +export function nextTimelineUsage( + usage: ReadonlyMap, + event: TimelineEvent +): { key: string; value: Usage } | null { + if (event.type !== "usage" || typeof event.detail !== "string" || !event.detail) return null; + try { + const row = JSON.parse(event.detail) as Usage; + const key=JSON.stringify([event.sessionId,row.source,row.counterId ?? ""]); + if (row.cumulative === true) { + if (isTimelineCount(row.total) && typeof row.source === "string") { + const input = row.input === null || isTimelineCount(row.input) ? row.input : null; + const output = row.output === null || isTimelineCount(row.output) ? row.output : null; + const cost = typeof row.cost === "number" && Number.isFinite(row.cost) && row.cost >= 0 ? row.cost : null; + return { key, value: { input, output, total: row.total, cost, source: row.source, + cumulative: true, + ...(typeof row.counterId === "string" && row.counterId.length <= 200 ? { counterId: row.counterId } : {}), + ...timelineUsageContext(row) } }; + } + return null; + } + if (!isTimelineCount(row.input) || !isTimelineCount(row.output) || typeof row.source !== "string") return null; + const previous = usage.get(key); + return { key, value: { input: (previous?.input ?? 0) + row.input!, output: (previous?.output ?? 0) + row.output!, + cost: row.cost !== null && typeof row.cost === "number" && Number.isFinite(row.cost) && (!previous || previous.cost !== null) + ? (previous?.cost ?? 0) + row.cost : null, + source: row.source,...timelineUsageContext(row) } }; + } catch { /* An invalid or truncated journal line has no usage effect. */ return null; } +} + +export function timelineUsageContext(value: { provider?: unknown; model?: unknown; accountId?: unknown; taskId?: unknown }): TimelineUsageContext { + const context: TimelineUsageContext = {}; + for (const key of ["provider", "model", "accountId", "taskId"] as const) { + const entry = value[key]; + if (typeof entry === "string" && entry.length > 0 && entry.length <= 200) context[key] = entry; + } + return context; +} + +export function isTimelineCount(value: unknown): value is number { return Number.isSafeInteger(value) && (value as number) >= 0; } + +export function indexTimelineTaskSession( + taskSessions:Map>, + event:TimelineEvent, + segment:string +):void { + if(typeof event.taskId!=="string" || !event.taskId || event.taskId.length>200)return; + let sessions=taskSessions.get(event.taskId); + if(!sessions){sessions=new Map();taskSessions.set(event.taskId,sessions);} + let descriptor=sessions.get(event.sessionId); + if(!descriptor) { + descriptor={id:event.sessionId,taskId:event.taskId, + title:typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle ? event.sessionTitle : event.sessionId.slice(0,200), + types:new Map(),lastSegment:segment}; + sessions.set(event.sessionId,descriptor); + } + if(typeof event.type==="string" && event.type)descriptor.types.set(event.type,segment); + if(typeof event.sessionTitle==="string" && event.sessionTitle.length<=200 && event.sessionTitle)descriptor.title=event.sessionTitle; + descriptor.lastSegment=segment; +} + +export function applyTimelineBudgetUsageEvent( + contributions:Map, + baselines:Map, + event:any +):void { + if(event.type!=="usage" || typeof event.detail!=="string" || !event.detail)return; + try { + const row=JSON.parse(event.detail) as Usage; + if(typeof row.source!=="string")return; + const context=timelineUsageContext(row),identity=budgetCounterIdentity(event.sessionId,{...row,...context}); + if(row.cumulative===true) { + if(!isTimelineCount(row.total))return; + const current:Usage={input:row.input===null || isTimelineCount(row.input) ? row.input : null, + output:row.output===null || isTimelineCount(row.output) ? row.output : null,total:row.total, + cost:typeof row.cost==="number" && Number.isFinite(row.cost) && row.cost>=0 ? row.cost : null, + source:row.source,cumulative:true, + ...(typeof row.counterId==="string" && row.counterId.length<=200 ? {counterId:row.counterId} : {}),...context}; + const previousState=baselines.get(identity),previous=previousState?.current; + if(previous===undefined && row.baseline===true) { + // Usage before the resume belongs to earlier runs; later samples count from here. + baselines.set(identity,{current,taskOwner:taskOwner(event.sessionId,current),mixedOwners:false,reportedCostSeen:current.cost!==null,fromBaseline:true}); + return; + } + const reset=previous!==undefined && current.total!=0; + addBudgetContribution(contributions,{sessionId:event.sessionId,identity,...context, + mode:reported ? "reported" : "priced",tokens:row.input+row.output,input:row.input,output:row.output, + reportedCostUsd:reported ? row.cost! : null}); + } catch { /* An invalid or truncated journal line has no budget effect. */ } +} + +export function budgetCounterIdentity(sessionId:string,row:Usage):string { + return row.counterId===undefined + ? JSON.stringify([sessionId,row.source,""]) + : JSON.stringify([row.provider ?? "",row.accountId ?? "",row.source,row.counterId]); +} +function budgetCounterDelta(current:number|null,previous:number|null,reset:boolean):number|null { + if(current===null)return null; + if(previous===null || reset)return current; + return current>=previous ? current-previous : current; +} +function addBudgetContribution(map:Map,row:BudgetUsageContribution):void { + const key=budgetContributionKey(row); + const previous=map.get(key); + if(!previous){map.set(key,{...row});return;} + previous.tokens+=row.tokens; + previous.input=previous.input===null || row.input===null ? null : previous.input+row.input; + previous.output=previous.output===null || row.output===null ? null : previous.output+row.output; + if(row.mode==="reported")previous.reportedCostUsd=(previous.reportedCostUsd ?? 0)+(row.reportedCostUsd ?? 0); +} +function budgetContributionKey(row:BudgetUsageContribution):string { + return JSON.stringify([row.sessionId,row.identity,row.taskId ?? "",row.provider ?? "",row.model ?? "",row.mode]); +} +function taskOwner(sessionId:string,row:Usage):string {return row.taskId===undefined ? `session:${sessionId}` : `task:${row.taskId}`;} +function coverEarlierPricedCost(contributions:Map,identity:string,taskId:string|undefined,sessionId:string):void { + for(const [key,row] of [...contributions]) { + if(row.identity!==identity || row.mode!=="priced")continue; + if(taskId!==undefined ? row.taskId!==taskId : row.sessionId!==sessionId || row.taskId!==undefined)continue; + contributions.delete(key);row.mode="covered";contributions.set(budgetContributionKey(row),row); + } +} +function setReportedCostBase(contributions:Map,sessionId:string,identity:string,row:Usage,cost:number):void { + const base:BudgetUsageContribution={sessionId,identity,...timelineUsageContext(row),mode:"reported-base",tokens:0,input:null,output:null,reportedCostUsd:cost}; + const owner=row.taskId ?? `session:${sessionId}`; + contributions.set(JSON.stringify(["reported-base",identity,owner]),base); +} diff --git a/src/main/services/TimelineJournalReader.ts b/src/main/services/TimelineJournalReader.ts new file mode 100644 index 000000000..66152bea2 --- /dev/null +++ b/src/main/services/TimelineJournalReader.ts @@ -0,0 +1,60 @@ +import { createReadStream } from "node:fs"; +import type { TimelineEvent } from "../../shared/backlog.ts"; + +const STREAM_HIGH_WATER_MARK = 64 * 1024; +const EVENT_LOOP_YIELD_CHARS = 256 * 1024; + +/** + * Visits valid journal records without retaining a whole segment or an event array. + * Malformed records are ignored, as they are by SessionTimelineService.read(). + * A line is kept intact until JSON parsing so valid oversized imported records keep + * their existing behavior. + */ +export async function forEachTimelineEvent( + path: string, + visit: (event: TimelineEvent) => boolean | void +): Promise { + const stream = createReadStream(path, { encoding: "utf8", highWaterMark: STREAM_HIGH_WATER_MARK }); + let pendingParts: string[] = []; + let workSinceYield = 0; + let yieldCount = 0; + + const deliver = (line: string): boolean => { + let event: TimelineEvent; + try { event = JSON.parse(line) as TimelineEvent; } + catch { return false; } + if (!event || typeof event.id !== "string" || typeof event.sessionId !== "string" || typeof event.at !== "number") return false; + return visit(event) === true; + }; + const yieldToEventLoop = (): Promise => new Promise(resolve => setImmediate(resolve)); + + for await (const chunk of stream) { + let from = 0; + while (from < chunk.length) { + const newline = chunk.indexOf("\n", from); + const end = newline < 0 ? chunk.length : newline; + const part = chunk.slice(from, end); + if (part) { + pendingParts.push(part); + workSinceYield += part.length; + } + from = newline < 0 ? chunk.length : newline + 1; + if (newline >= 0) { + const line = pendingParts.length === 0 ? "" + : pendingParts.length === 1 ? pendingParts[0] + : pendingParts.join(""); + pendingParts.length = 0; + if (deliver(line)) return yieldCount; + workSinceYield++; + } + if (workSinceYield >= EVENT_LOOP_YIELD_CHARS) { + workSinceYield = 0; + await yieldToEventLoop(); + yieldCount++; + } + } + } + + if (pendingParts.length > 0) deliver(pendingParts.length === 1 ? pendingParts[0] : pendingParts.join("")); + return yieldCount; +} diff --git a/src/main/services/TimelineSearchIndex.ts b/src/main/services/TimelineSearchIndex.ts new file mode 100644 index 000000000..c418fbf03 --- /dev/null +++ b/src/main/services/TimelineSearchIndex.ts @@ -0,0 +1,64 @@ +export interface TimelineSegmentSearchIndex { + size: number; + mtimeMs: number; + sessionIds: Set; + types: Set; + /** False when a malformed oversized line could not be indexed; such a segment must be read. */ + complete: boolean; + /** Four 32K-bit bloom filters per segment, with no false negatives. */ + bloom: Uint8Array; +} + +const BLOOM_BYTES = 16 * 1024; +const BLOOM_BITS = BLOOM_BYTES * 8; +const BLOOM_MASK = BLOOM_BITS - 1; + +export function createTimelineSegmentSearchIndex(size = 0, mtimeMs = 0): TimelineSegmentSearchIndex { + return { size, mtimeMs, sessionIds: new Set(), types: new Set(), complete: true, bloom: new Uint8Array(BLOOM_BYTES) }; +} + +export function addTimelineSearchEvent( + index: TimelineSegmentSearchIndex, + event: { sessionId: string; type: string; summary?: string; detail?: string } +): void { + index.sessionIds.add(event.sessionId); + if (event.type) index.types.add(event.type); + // Match page()'s template interpolation even for legacy rows with malformed optional fields. + const text = `${String(event.summary)}\n${event.detail === null || event.detail === undefined ? "" : String(event.detail)}`.toLocaleLowerCase(); + for (let offset = 0; offset + 2 < text.length; offset++) { + const first = text.charCodeAt(offset); + const second = text.charCodeAt(offset + 1); + const third = text.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + setBit(index.bloom, hash & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK); + setBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK); + } +} + +export function timelineSegmentMayMatch( + index: Pick, + sessionIds: ReadonlySet, + types: ReadonlySet | null, + query: string | undefined +): boolean { + if (!index.complete) return true; + if (sessionIds.size === 0 || ![...sessionIds].some(id => index.sessionIds.has(id))) return false; + if (types && ![...types].some(type => index.types.has(type))) return false; + if (!query || query.length < 3) return true; + for (let offset = 0; offset + 2 < query.length; offset++) { + const first = query.charCodeAt(offset); + const second = query.charCodeAt(offset + 1); + const third = query.charCodeAt(offset + 2); + const hash = (Math.imul(first, 0x1f1f1f1f) ^ Math.imul(second, 0x45d9f3b) ^ Math.imul(third, 0x119de1f3)) >>> 0; + if (!hasBit(index.bloom, hash & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash, 0x9e3779b1) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x85ebca6b, 0xc2b2ae35) >>> 17 & BLOOM_MASK) + || !hasBit(index.bloom, Math.imul(hash ^ 0x27d4eb2f, 0x165667b1) >>> 17 & BLOOM_MASK)) return false; + } + return true; +} + +function setBit(bits: Uint8Array, offset: number): void { bits[offset >>> 3] = bits[offset >>> 3]! | (1 << (offset & 7)); } +function hasBit(bits: Uint8Array, offset: number): boolean { return (bits[offset >>> 3]! & (1 << (offset & 7))) !== 0; } diff --git a/src/main/services/UsagePrices.ts b/src/main/services/UsagePrices.ts new file mode 100644 index 000000000..6e85a24e1 --- /dev/null +++ b/src/main/services/UsagePrices.ts @@ -0,0 +1,37 @@ +import {mkdir,readFile,rename,writeFile} from "node:fs/promises"; +import {dirname} from "node:path"; +import {randomUUID} from "node:crypto"; +import type {UsagePrice} from "../../shared/backlog.ts"; + +/** Human-entered prices. Empty by default; missing model/breakdown never produces a guessed cost. */ +export class UsagePrices { + private readonly path:string; + private rows:UsagePrice[]=[]; + private writes:Promise=Promise.resolve(); + private listeners=new Set<()=>void>(); + constructor(path:string){this.path=path;} + async load():Promise{ + try{const text=await readFile(this.path,"utf8");if(text.length>256_000)throw new Error("Price table is too large.");this.rows=validate(JSON.parse(text));} + catch(error){if(!(error && typeof error==='object' && 'code' in error && error.code==='ENOENT'))throw error;} + } + get():UsagePrice[]{return structuredClone(this.rows);} + subscribe(listener:()=>void):()=>void {this.listeners.add(listener);return ()=>this.listeners.delete(listener);} + async set(rows:UsagePrice[]):Promise{ + const next=validate(rows),payload=JSON.stringify(next); + const operation=this.writes.catch(()=>undefined).then(async()=>{ + await mkdir(dirname(this.path),{recursive:true,mode:0o700});const temporary=`${this.path}.${randomUUID()}.tmp`; + await writeFile(temporary,payload,{mode:0o600,flag:'wx'});await rename(temporary,this.path);this.rows=next; + });this.writes=operation;await operation;for(const listener of this.listeners)listener();return this.get(); + } +} +function validate(value:unknown):UsagePrice[]{ + if(!Array.isArray(value) || value.length>200)throw new Error("Price table accepts at most 200 models."); + const keys=new Set(); + return value.map(row=>{ + if(!row || typeof row!=='object' || typeof row.provider!=='string' || !/^[a-z][a-z0-9-]{0,39}$/.test(row.provider) + || typeof row.model!=='string' || !row.model.trim() || row.model.length>200 + || ![row.inputPerMillion,row.outputPerMillion].every(n=>typeof n==='number' && Number.isFinite(n) && n>=0 && n<=1_000_000))throw new Error("Invalid model price."); + const key=`${row.provider}:${row.model}`;if(keys.has(key))throw new Error("Duplicate model price.");keys.add(key); + return {provider:row.provider,model:row.model.trim(),inputPerMillion:row.inputPerMillion,outputPerMillion:row.outputPerMillion}; + }); +} diff --git a/src/main/services/accountHomeIsolation.ts b/src/main/services/accountHomeIsolation.ts new file mode 100644 index 000000000..fec140bd6 --- /dev/null +++ b/src/main/services/accountHomeIsolation.ts @@ -0,0 +1,80 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import type { ProviderId } from "../../shared/contracts.ts"; + +/** The Accounts service id is fixed by the host; plugin replies cannot nominate a private-data exception. */ +export const ACCOUNTS_PLUGIN_ID = "canvastty-accounts"; + +/** The account a launch is billed to: an empty or "none" selection means the provider's own default sign-in. */ +export function selectedAccountId(options: Readonly> | undefined): string { + const values = options?.[ACCOUNTS_PLUGIN_ID]; + const selected = values && typeof values === "object" ? (values as Record).account : undefined; + return typeof selected === "string" && selected !== "" && selected !== "none" ? selected : "default"; +} + +/** + * Returns a candidate account home only for the host's Accounts launch contributor and matching CLI provider. + * The data directory is populated by PluginManager, never by plugin RPC. Filesystem links and existence are checked + * again immediately before the operating-system sandbox is built. + */ +export function selectedAccountHome( + provider: ProviderId, + pluginId: string, + dataDir: string | undefined, + selectedAccountId: unknown, + contributedEnv: Readonly> +): string | undefined { + if (pluginId !== ACCOUNTS_PLUGIN_ID || !dataDir || !isAbsolute(dataDir)) return undefined; + if (typeof selectedAccountId !== "string" || !selectedAccountId || selectedAccountId === "none") return undefined; + const variable = provider === "codex" ? "CODEX_HOME" : provider === "claude" ? "CLAUDE_CONFIG_DIR" : undefined; + if (!variable) return undefined; + const value = contributedEnv[variable]; + if (!value || !isAbsolute(value)) return undefined; + + const accountData = resolve(dataDir); + const homes = join(accountData, "homes"); + const selected = resolve(value); + const prefix = `${provider}-`; + if (basename(accountData) !== ACCOUNTS_PLUGIN_ID || basename(dirname(accountData)) !== "plugin-data" + || selected !== value || dirname(selected) !== homes || basename(selected) !== `${prefix}${selectedAccountId}`) return undefined; + return selected; +} + +/** + * Checks the candidate against this app's own private data directory at wrap time. The plugin-data directory, + * Accounts directory, homes directory and selected child must all be real directories, not symlinks. + */ +export function validateSelectedAccountHome(userDataPath: string, provider: ProviderId, candidate: string): string { + if (provider !== "codex" && provider !== "claude") { + throw new Error("Only the Accounts Codex or Claude home can be reopened by isolation. The agent was not started."); + } + const userData = resolve(userDataPath); + const realUserData = realpathSync.native(userData); + const pluginData = join(userData, "plugin-data"); + const accountsData = join(pluginData, ACCOUNTS_PLUGIN_ID); + const homes = join(accountsData, "homes"); + const selected = resolve(candidate); + const prefix = `${provider}-`; + if (!isAbsolute(candidate) || candidate !== selected || dirname(selected) !== homes + || !basename(selected).startsWith(prefix) || basename(selected).length <= prefix.length) { + throw new Error("The selected Accounts CLI home must be a direct child for this provider. The agent was not started."); + } + + const expected = [ + [pluginData, join(realUserData, "plugin-data")], + [accountsData, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID)], + [homes, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes")], + [selected, join(realUserData, "plugin-data", ACCOUNTS_PLUGIN_ID, "homes", basename(selected))] + ] as const; + try { + for (const [path, canonical] of expected) { + const info = lstatSync(path); + if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(path) !== canonical) { + throw new Error("not a real directory"); + } + } + } catch { + throw new Error("The selected Accounts CLI home is missing or crosses a symbolic link. The agent was not started."); + } + return expected.at(-1)![1]; +} diff --git a/src/main/services/agent-browser/OrchestrationTools.ts b/src/main/services/agent-browser/OrchestrationTools.ts index ba6afebf3..b4454e0f1 100644 --- a/src/main/services/agent-browser/OrchestrationTools.ts +++ b/src/main/services/agent-browser/OrchestrationTools.ts @@ -1,8 +1,9 @@ import type { OrchestrationCommandHandler, OrchestrationRequest } from "./orchestration-protocol.ts"; +import { selectedAccountId } from "../accountHomeIsolation.ts"; import { orchestrationBridgeError } from "./orchestration-protocol.ts"; import type { AgentProviderId, ProviderId, SessionRole } from "../../../shared/contracts.ts"; import { launchEffortProblem, launchModelProblem } from "../../../shared/launchModel.ts"; -import { PromptNotDeliveredError, type AgentControlService, type SpawnAgentRequest } from "../AgentControlService.ts"; +import { PromptNotDeliveredError, type AgentControlService, type AgentResult, type AgentReviewResult, type SpawnAgentRequest } from "../AgentControlService.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; import type { PluginAgentTools } from "../PluginAgentTools.ts"; import { @@ -15,6 +16,18 @@ import { import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; import { listProviderDirectory, type ProviderDirectorySources } from "../providerDirectory.ts"; import type { McpToolDefinition } from "../../../agent-browser/orchestration-catalog.mjs"; +import type { OrchestrationBudgetService } from "../OrchestrationBudgetService.ts"; +import type { OrchestrationTaskBoard, OrchestrationTaskPatch } from "../OrchestrationTaskBoard.ts"; +import type { OrchestrationTemplateService } from "../OrchestrationTemplateService.ts"; + +const TASK_TOOL_NAMES = new Set(["list_tasks", "claim_task", "update_task", "complete_task"]); +const ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS = new Set(["retry_agent"]); + +export interface ScopedOrchestrationIntegrations { + taskBoard?: OrchestrationTaskBoard; + budget?: Pick; + templates?: OrchestrationTemplateService; +} /** * The only bridge between the orchestration MCP surface and session control. @@ -26,22 +39,33 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { private readonly control: AgentControlService; private readonly plugins: Pick | null; private readonly providers: ProviderDirectorySources; + private readonly integrations: ScopedOrchestrationIntegrations; constructor( control: AgentControlService, plugins: Pick | null = null, - providers: ProviderDirectorySources = { cli: () => null, limits: () => null } + providers: ProviderDirectorySources = { cli: () => null, limits: () => null }, + integrations: ScopedOrchestrationIntegrations = {} ) { this.control = control; this.plugins = plugins; this.providers = providers; + this.integrations = integrations; } - /** Orchestrators see the core tools; every role sees the plugin tools that list it (EP-6). */ + /** Orchestrators see core tools; agents also see task-board tools scoped to their own root. */ listTools(sessionId: string): McpToolDefinition[] { + if (this.control.isReadOnlyReviewer(sessionId)) return []; const session = this.control.status(sessionId); + const core = ORCHESTRATION_TOOL_DEFINITIONS.filter((tool) => { + if (TASK_TOOL_NAMES.has(tool.name)) return Boolean(this.integrations.taskBoard); + if (tool.name === "get_task_budget") return Boolean(this.integrations.budget); + if (tool.name === "list_orchestration_templates" || tool.name === "apply_orchestration_template") return Boolean(this.integrations.templates); + if (ORCHESTRATOR_ONLY_ADDITIONAL_TOOLS.has(tool.name)) return session.role === "orchestrator"; + return session.role === "orchestrator"; + }); return [ - ...(session.role === "orchestrator" ? ORCHESTRATION_TOOL_DEFINITIONS : []), + ...core, ...(this.plugins?.list(session.role, session.provider) ?? []) ]; } @@ -50,7 +74,20 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { try { if (signal?.aborted) throw canceledError(); const session = this.control.status(sessionId); + if (this.control.isReadOnlyReviewer(sessionId)) { + throw orchestrationBridgeError("INVALID_REQUEST", "Read-only reviewers cannot call agent or plugin tools.", false); + } if (isPluginOrchestrationTool(request.tool)) return await this.plugin(sessionId, session, request); + if (TASK_TOOL_NAMES.has(request.tool)) { + if (session.provider === "terminal") throw orchestrationBridgeError("INVALID_REQUEST", "Plain terminals cannot use the orchestration task board.", false); + return await this.taskTool(sessionId, request.tool, request.arguments); + } + if (request.tool === "get_task_budget") { + const budget = this.control.taskBudget(sessionId); + return budget ? ({ budget } as Record) : { available: false, reason: "No task budget is configured." }; + } + if (request.tool === "list_orchestration_templates") return await this.listTemplates(sessionId); + if (request.tool === "apply_orchestration_template") return await this.applyTemplate(sessionId, request.arguments); // Plugin tools may reach other roles' sessions through the same bridge; the core tools never do. if (session.role !== "orchestrator") { throw orchestrationBridgeError("INVALID_REQUEST", "Only orchestrator sessions can use CanvasTTY's agent tools.", false); @@ -67,11 +104,13 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { case "observe_agent": return this.observe(sessionId, request.arguments); case "get_agent_result": - return this.result(sessionId, request.arguments); + return await this.result(sessionId, request.arguments); case "cancel_agent": return this.cancel(sessionId, request.arguments); case "list_agents": return this.list(sessionId); + case "retry_agent": + return await this.retry(sessionId, request.arguments, signal); default: throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported orchestration tool.", false); } @@ -98,6 +137,7 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", "That plugin tool is not available to this session.", false); } try { + this.control.assertInputAllowed(sessionId); const result = await this.plugins.call(sessionId, session.role, request.tool, request.arguments); return { pluginTool: true, text: result.content, isError: result.isError }; } catch (error) { @@ -110,6 +150,74 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { return listProviderDirectory(this.providers, pluginTools) as unknown as Record; } + private async taskTool(sessionId: string, tool: string, args: Record): Promise> { + const board = this.integrations.taskBoard; + if (!board) throw orchestrationBridgeError("INVALID_REQUEST", "The shared task board is not configured.", false); + const lineage = this.control.lineage(sessionId); + const root = this.control.taskRoot(sessionId); + const actor = lineage[0]!; + const authorityId=actor.role==="orchestrator" && !actor.parentSessionId ? root.id : sessionId; + try { + switch (tool) { + case "list_tasks": + return await board.listTasks(root.cwd, root.id); + case "claim_task": + return { task: await board.claimTask(root.cwd, root.id, sessionId, this.control.maskText(actor.title,65_536), args.taskId as string) }; + case "update_task": { + const patch: OrchestrationTaskPatch = {}; + for (const key of ["title", "description", "progress", "status", "ownerSessionId", "ownerName", "dependencies"]) { + if (Object.prototype.hasOwnProperty.call(args, key)) Object.assign(patch, { [key]: args[key] }); + } + if(patch.ownerSessionId && this.control.taskRoot(patch.ownerSessionId).id!==root.id)throw new Error("Task owner must belong to this orchestration tree."); + for(const key of ["title","description","progress","ownerName"] as const)if(typeof patch[key]==="string")patch[key]=this.control.maskText(patch[key]!,65_536); + return { task: await board.updateTask(root.cwd, root.id, authorityId, args.taskId as string, patch) }; + } + case "complete_task": + return { task: await board.completeTask(root.cwd, root.id, authorityId, args.taskId as string, this.control.maskText(args.result as string,65_536)) }; + default: + throw orchestrationBridgeError("INVALID_REQUEST", "Unsupported task-board tool.", false); + } + } catch (error) { + if (error && typeof error === "object" && "bridgeError" in error) throw error; + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : "Task-board operation failed.", false); + } + } + + private async listTemplates(sessionId: string): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const listing = await service.list(root.cwd); + return { ...listing, templates: listing.templates.filter(template => template.trusted === true) } as unknown as Record; + } + + private async applyTemplate(sessionId: string, args: Record): Promise> { + const service = this.integrations.templates; + if (!service) throw orchestrationBridgeError("INVALID_REQUEST", "Orchestration templates are not configured.", false); + const root = this.control.taskRoot(sessionId); + const available = await service.list(root.cwd); + const template = available.templates.find((item) => item.id === args.templateId); + if (!template) throw orchestrationBridgeError("INVALID_REQUEST", `Template "${String(args.templateId).slice(0, 80)}" is not available. Call list_orchestration_templates for the current choices.`, false); + return { + templateId: template.id, + name: template.name, + expectedSubagents: template.expectedSubagents, + instructions: service.instructions(template).replace("{{TASK}}", args.task as string), + ...(available.errors.length > 0 ? { templateErrors: available.errors } : {}) + }; + } + + private async retry(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { + const target = args.sessionId as string; + if (target === orchestratorId) throw orchestrationBridgeError("INVALID_REQUEST", "retry_agent retries a subagent, not this session.", false); + this.requireOwned(orchestratorId, target); + if (signal?.aborted) throw canceledError(); + const created = await this.control.retry(target, args.reason as string | undefined, signal); + // Retry owns cancellation cleanup and retains this same card's diagnostic history. + return { sessionId: created.id, provider: created.provider, title: created.title, profile: created.profile, + ...(created.model !== undefined ? { model: created.model } : {}), ...(created.effort !== undefined ? { effort: created.effort } : {}) }; + } + private async wait(orchestratorId: string, args: Record, signal?: AbortSignal): Promise> { const target = args.sessionId as string; // Its own session is in its subtree, but waiting on itself would only ever time out. @@ -118,12 +226,17 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } this.requireOwned(orchestratorId, target); const seconds = typeof args.timeoutSeconds === "number" ? args.timeoutSeconds : DEFAULT_AGENT_WAIT_SECONDS; + // OpenCode's MCP client can impose a 60-second request deadline, even when the helper allows longer. + const clientLimitMs = this.control.status(orchestratorId).provider === "opencode" ? 50_000 : 100_000; try { const result = await this.control.waitFor(target, { - timeoutMs: Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000, + timeoutMs: Math.min(clientLimitMs, Math.min(MAX_AGENT_WAIT_SECONDS, Math.max(1, seconds)) * 1_000), + deferReview: true, ...(signal ? { signal } : {}) }); - return { ...result }; + if (result.reason === "closed") return { ...result }; + return { ...result, ...(result.reason === "timeout" || result.review?.status === "pending" + ? { message: "Still running. Call wait_for_agent again for progress." } : {}) }; } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === "AbortError")) throw canceledError(); throw error; @@ -135,16 +248,50 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { throw orchestrationBridgeError("INVALID_REQUEST", unknownProviderMessage(args.provider), false); } const provider = args.provider as AgentProviderId; - const problem = (args.model !== undefined ? launchModelProblem(provider, args.model) : null) + const hasExplicitModel = args.model !== undefined; + const problem = (hasExplicitModel ? launchModelProblem(provider, args.model) : null) ?? (args.effort !== undefined ? launchEffortProblem(provider, args.effort) : null); if (problem) throw orchestrationBridgeError("INVALID_REQUEST", `${problem} Call list_providers for what ${provider} takes.`, false); const profile = this.control.profileFor(orchestratorId, provider, args.profile); if ("error" in profile) throw orchestrationBridgeError("INVALID_REQUEST", profile.error, false); - if (args.model !== undefined) { + if (hasExplicitModel) { let unknown: string | null = null; try { unknown = await this.providers.checkModel?.(provider, args.model as string) ?? null; } catch { unknown = null; } if (unknown) throw orchestrationBridgeError("INVALID_REQUEST", unknown, false); } + if (args.reviewModel !== undefined) { + if (args.review !== true) throw orchestrationBridgeError("INVALID_REQUEST", "reviewModel can be set only when review:true.", false); + const reviewProblem = launchModelProblem(provider, args.reviewModel); + if (reviewProblem) throw orchestrationBridgeError("INVALID_REQUEST", `${reviewProblem} Call list_providers for the valid reviewer models.`, false); + const unknownReviewModel = await this.providers.checkModel?.(provider, args.reviewModel as string) ?? null; + if (unknownReviewModel) throw orchestrationBridgeError("INVALID_REQUEST", unknownReviewModel, false); + if (hasExplicitModel && args.reviewModel === args.model) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the worker model.", false); + } + } + let model = hasExplicitModel ? args.model as string : undefined; + let effort = args.effort as SpawnAgentRequest["effort"] | undefined; + if (!hasExplicitModel && this.control.taskBudget(orchestratorId)?.paused) { + throw orchestrationBridgeError("INVALID_REQUEST", this.control.taskBudget(orchestratorId)?.reason ?? "This task's budget is exhausted.", false); + } + // A spawn without an account runs on the orchestrator's account (never silently on the CLI's default model). + let launchOptions = args.launchOptions as SpawnAgentRequest["launchOptions"]; + let accountSource: "inherited" | "explicit" | "none" = "none"; + if (typeof this.control.subagentAccount === "function") { + try { + const account = this.control.subagentAccount(orchestratorId, provider, launchOptions); + launchOptions = account.launchOptions; + accountSource = account.source; + } catch (error) { + throw orchestrationBridgeError("INVALID_REQUEST", error instanceof Error ? error.message : String(error), false); + } + } + const accountId = selectedAccountId(launchOptions as Record | undefined); + // A model account supplies its model and remains visible in the launch result. + const accountChosen = accountId !== "default"; + if (args.reviewModel !== undefined && model === args.reviewModel) { + throw orchestrationBridgeError("INVALID_REQUEST", "The review model must differ from the selected worker model.", false); + } if (signal?.aborted) throw canceledError(); const created = await this.control.spawn({ parentSessionId: orchestratorId, @@ -152,9 +299,11 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { cwd: args.cwd as string, ...(args.title !== undefined ? { title: args.title as string } : {}), ...(args.prompt !== undefined ? { initialPrompt: args.prompt as string } : {}), - ...(args.launchOptions !== undefined ? { launchOptions: args.launchOptions as SpawnAgentRequest["launchOptions"] } : {}), - ...(args.model !== undefined ? { model: args.model as string } : {}), - ...(args.effort !== undefined ? { effort: args.effort as SpawnAgentRequest["effort"] } : {}), + ...(launchOptions !== undefined ? { launchOptions } : {}), + ...(model !== undefined ? { model } : {}), + ...(effort !== undefined ? { effort } : {}), + ...(args.review === true ? { review: true } : {}), + ...(typeof args.reviewModel === "string" ? { reviewModel: args.reviewModel } : {}), profile: profile.profile }, signal).catch((error: unknown) => { if (signal?.aborted) throw canceledError(); @@ -169,6 +318,12 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { } throw canceledError(); } + const servedBy = { + provider, + account: accountChosen ? accountId : null, + accountSource: accountChosen ? accountSource : "none", + model: created.model ?? (accountChosen ? `set by model account ${accountId.slice(0, 40)}` : defaultModelText(provider)) + }; return { sessionId: created.id, provider: created.provider, @@ -177,7 +332,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { profile: created.profile, ...(profile.inherited ? { profileInherited: true } : {}), ...(created.model !== undefined ? { model: created.model } : {}), - ...(created.effort !== undefined ? { effort: created.effort } : {}) + ...(created.effort !== undefined ? { effort: created.effort } : {}), + servedBy, + ...(args.review === true ? { reviewRequested: true } : {}), }; } @@ -213,9 +370,9 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { }; } - private result(orchestratorId: string, args: Record): Record { + private async result(orchestratorId: string, args: Record): Promise> { this.requireOwned(orchestratorId, args.sessionId as string); - const result = this.control.result(args.sessionId as string); + const result = await this.control.resultWithReview(args.sessionId as string, { deferReview: true }); return { sessionId: result.sessionId, state: result.state, @@ -223,7 +380,8 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { exitCode: result.exitCode, output: result.output, ...(result.exitLines ? { exitLines: result.exitLines } : {}), - ...(result.answer ? { answer: result.answer } : {}) + ...(result.answer ? { answer: result.answer } : {}), + ...(result.review ? { review: result.review } : {}) }; } @@ -260,3 +418,10 @@ export class ScopedOrchestrationHandler implements OrchestrationCommandHandler { function canceledError(): Error { return orchestrationBridgeError("CANCELED", "Orchestration command was canceled.", true); } + +/** What serves a subagent that names neither a model nor a model account. */ +function defaultModelText(provider: AgentProviderId): string { + return provider === "opencode" + ? "OpenCode's default model (its own configuration decides; with none set OpenCode uses its free OpenCode Zen model on opencode.ai)" + : `${provider}'s default model`; +} diff --git a/src/main/services/agent-runtime/AgentRuntimeBridge.ts b/src/main/services/agent-runtime/AgentRuntimeBridge.ts index 18b6ef2d2..5df176bb9 100644 --- a/src/main/services/agent-runtime/AgentRuntimeBridge.ts +++ b/src/main/services/agent-runtime/AgentRuntimeBridge.ts @@ -1,4 +1,5 @@ import type { ProviderId } from "../../../shared/contracts.ts"; +import { dirname, isAbsolute, join } from "node:path"; import { AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, @@ -38,6 +39,7 @@ export interface PreparedAgentRuntimePtyLaunch { export interface AgentRuntimeLaunchCoordinator { prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch; currentStatus(terminalSessionId: string): RuntimeLifecycleState | null; + readableRuntimePaths?(): readonly string[]; } export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions { @@ -52,11 +54,12 @@ export interface AgentRuntimeBridgeOptions extends ProviderRuntimeLaunchOptions } export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { + private readonly runtimeReadPaths: readonly string[]; private readonly gateway: RuntimeGateway; private readonly providers: ProviderRuntimeLaunchAdapters; /** Running sessions and whether each got the decision hook. */ /** Live launches by card id; the entry object is the launch identity a late cleanup checks against. */ - private readonly activeSessions = new Map(); + private readonly activeSessions = new Map(); private coreHooksEnabled: boolean; private readonly wantsDecisions: AgentRuntimeBridgeOptions["wantsDecisions"]; private readonly decisionBudgetMs: AgentRuntimeBridgeOptions["decisionBudgetMs"]; @@ -68,21 +71,33 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { this.decisionBudgetMs = options.decisionBudgetMs; this.claudeHttpHooks = options.claudeHttpHooks; this.providers = new ProviderRuntimeLaunchAdapters(options); + // File grants, never the containing source tree: a reviewer may review CanvasTTY itself. + const helpers = [options.helper, ...(options.permissionGate ? [options.permissionGate] : []), + ...(options.pluginHooks ? [options.pluginHooks.runner] : [])]; + const runtimeFiles = ["hook-helper.mjs", "permission-gate.mjs", "runtime-client.mjs", "runtime-protocol.mjs", + "ndjson.mjs", "path-inside.mjs", "opencode-plugin.mjs", "opencode-final-answer.mjs", "opencode-decisions.mjs", "omp-extension.mjs", + "plugin-hook-runner.mjs", "plugin-hook-dispatch.mjs"]; + this.runtimeReadPaths = Object.freeze([...new Set([ + ...helpers.flatMap(helper => [helper.command, ...helper.args.filter(isAbsolute)]), + ...runtimeFiles.map(file => join(dirname(options.openCodePluginPath), file))])]); this.coreHooksEnabled = options.coreHooksEnabled !== false; if (options.recoverOnStart) this.providers.recoverConfigurations(); } + readableRuntimePaths(): readonly string[] { return this.runtimeReadPaths; } + prepareLaunch(input: PrepareAgentRuntimeLaunchInput): PreparedAgentRuntimePtyLaunch { // The decision hook talks to the gateway over its own capability, with or without agent status hooks. const decisions = (input.decisions ?? this.wantsDecisions?.(input.provider) === true) && this.providers.decisionsSupported(input.provider); let budgetMs: number | undefined; try { budgetMs = decisions ? this.decisionBudgetMs?.(input.provider) : undefined; } catch { budgetMs = undefined; } - const capability = this.coreHooksEnabled || decisions + const captureResult = input.captureResult === true; + const capability = this.coreHooksEnabled || decisions || captureResult ? this.gateway.registerSession( input.terminalSessionId, input.provider, - input.captureResult === true, + captureResult, isLiveGrant(input.answerCaptureGrantExpiresAt) ? input.answerCaptureGrantExpiresAt : undefined, decisions, budgetMs @@ -92,12 +107,12 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { let prepared; try { prepared = this.providers.prepare(input.provider, input.terminalSessionId, this.coreHooksEnabled, decisions, budgetMs, - httpHookBase ?? undefined); + httpHookBase ?? undefined, captureResult); } catch (error) { if (capability) this.gateway.revokeTerminalSession(input.terminalSessionId, capability.capabilityToken); throw error; } - const launch = { decisions }; + const launch = { decisions, captureResult }; this.activeSessions.set(input.terminalSessionId, launch); let cleaned = false; return { @@ -153,9 +168,9 @@ export class AgentRuntimeBridge implements AgentRuntimeLaunchCoordinator { if (this.coreHooksEnabled === next) return; this.coreHooksEnabled = next; if (next) return; - // A session with the decision hook keeps its lease: its protection must not silently stop. - for (const [terminalSessionId, { decisions }] of this.activeSessions) { - if (!decisions) this.gateway.revokeTerminalSession(terminalSessionId); + // Decisions and explicitly requested result capture are independent of lifecycle UI updates. + for (const [terminalSessionId, { decisions, captureResult }] of this.activeSessions) { + if (!decisions && !captureResult) this.gateway.revokeTerminalSession(terminalSessionId); } } } diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 663875166..08e85aa8a 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -182,7 +182,8 @@ export class ProviderRuntimeLaunchAdapters { coreHooksEnabled = true, decisions = false, decisionBudgetMs?: number, - claudeHttpHookBase?: string + claudeHttpHookBase?: string, + captureResult = false ): PreparedProviderRuntimeLaunch { const pluginRegistrations = this.options.pluginHooks?.list(provider) ?? []; const gate = decisions && this.decisionsSupported(provider); @@ -193,8 +194,9 @@ export class ProviderRuntimeLaunchAdapters { const openCodeDecisions = gate && provider === "opencode"; // Only providers with a hook adapter get lifecycle configuration. Cursor, // MiniMax, Devin and Antigravity must never reach Grok's shared hook overlay. + const resultHooks = captureResult && (provider === "codex" || provider === "opencode"); const hasHooks = HOOK_PROVIDERS.has(provider) - && (coreHooksEnabled || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); + && (coreHooksEnabled || resultHooks || pluginCommands.length > 0 || (provider === "opencode" && (pluginRegistrations.length > 0 || openCodeDecisions))); const environment = hasHooks ? { ...(pluginRegistrations.length > 0 ? { @@ -210,7 +212,7 @@ export class ProviderRuntimeLaunchAdapters { return prepared(claudeHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, claudeHttpHookBase), environment); } if (provider === "codex") { - return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands), environment); + return prepared(codexHookArgs(this.options.helper, this.platform, coreHooksEnabled, pluginCommands, resultHooks), environment); } if (provider === "qwen") { const path = createQwenHookSettings({ @@ -641,11 +643,14 @@ function codexHookArgs( helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform, coreHooksEnabled: boolean, - pluginCommands: readonly ProviderHookCommand[] + pluginCommands: readonly ProviderHookCommand[], + captureResult = false ): string[] { validateHelper(helper); + const hooks = coreHooksEnabled ? CODEX_HOOKS + : captureResult ? CODEX_HOOKS.filter(({ event }) => ["SessionStart", "UserPromptSubmit", "PermissionRequest", "PostToolUse", "Stop"].includes(event)) : []; const grouped = groupProviderHookMappings([ - ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform) : []), + ...lifecycleCommands(hooks, helper, platform), ...pluginCommands ]); const events = Object.entries(grouped).flatMap(([event, mappings]) => { diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index 7ad4f9d48..97015bf66 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -877,7 +877,8 @@ function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" || event === "pre_llm_call" - || event === "session.status:busy"; + || event === "session.status:busy" + || event === "session.status:retry"; } function isRecord(value: unknown): value is Record { diff --git a/src/main/services/browser/BrowserElectronSmoke.ts b/src/main/services/browser/BrowserElectronSmoke.ts index b114cbf46..15ee3ceba 100644 --- a/src/main/services/browser/BrowserElectronSmoke.ts +++ b/src/main/services/browser/BrowserElectronSmoke.ts @@ -503,6 +503,7 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): }); void 0; `); + let wheelPulse: ReturnType | undefined; try { const initialViewport = { x: 500, @@ -517,13 +518,17 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): "globalThis.__canvasttyFreezeEvents?.some((event) => event.dataUrlLength > 0) === true" )); - owner.webContents.sendInputEvent({ + const sendWheel = (): void => owner.webContents.sendInputEvent({ type: "mouseWheel", x: 400, y: 300, deltaX: 8, deltaY: 12 }); + // Renderer round trips can exceed the idle boundary. Keep a real wheel stream active while + // checking surface transitions, then stop it to test the normal idle end separately. + sendWheel(); + wheelPulse = setInterval(sendWheel, Math.max(1, Math.floor(BROWSER_CANVAS_WHEEL_IDLE_MS / 5))); await new Promise((resolve) => setTimeout(resolve, 30)); service.setViewport({ ...initialViewport, x: 403 }); await waitUntil(async () => owner.webContents.executeJavaScript( @@ -543,6 +548,8 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): if (endedDuringPlaceholder) { throw new Error("Native/placeholder transition ended the active canvas wheel sequence."); } + clearInterval(wheelPulse); + wheelPulse = undefined; await waitUntil(async () => owner.webContents.executeJavaScript(` (() => { const events = globalThis.__canvasttyFreezeEvents ?? []; @@ -551,6 +558,7 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): })() `)); } finally { + clearInterval(wheelPulse); service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); await owner.webContents.executeJavaScript(` globalThis.__canvasttyFreezeOff?.(); diff --git a/src/main/services/configuredModel.ts b/src/main/services/configuredModel.ts new file mode 100644 index 000000000..3cc84d75a --- /dev/null +++ b/src/main/services/configuredModel.ts @@ -0,0 +1,20 @@ +import {readFileSync,statSync} from "node:fs"; +import {join} from "node:path"; +import type {AgentProviderId} from "../../shared/contracts.ts"; + +/** Reads a CLI's explicit default only. An absent/default alias is not guessed to be a catalog model. */ +export function configuredModel(provider:AgentProviderId,homes:{codex:string;claude:string;opencode:string}):string|null{ + try{ + let file:string; + switch(provider){ + case "codex":file=join(homes.codex,"config.toml");break; + case "claude":file=join(homes.claude,"settings.json");break; + case "opencode":file=join(homes.opencode,"opencode.json");break; + default:return null; + } + if(statSync(file).size>256_000)return null; + const text=readFileSync(file,"utf8"); + const model=provider==="codex" ? /^\s*model\s*=\s*["']([^"'\n]+)["']/mu.exec(text.split(/^\s*\[/mu)[0])?.[1] : JSON.parse(text).model; + return typeof model==="string" && /^[\w./:@+-]{1,200}$/u.test(model) ? model : null; + }catch{return null;} +} diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c290..f209f9e32 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join } from "node:path"; +import { delimiter, dirname, isAbsolute, join, relative } from "node:path"; import type { ProviderId, SessionIsolation } from "../../../shared/contracts.ts"; import { autoKind, PROFILE_RANK, type LaunchProfile } from "../../../shared/autoMode.ts"; import { LaunchRefusal } from "../launchRefusal.ts"; @@ -9,6 +9,8 @@ import { isolationPaths } from "./isolationPaths.ts"; import { seatbeltProfile } from "./seatbelt.ts"; import { bubblewrapArguments, projectHooks } from "./bubblewrap.ts"; import { LinuxHostPaths } from "./linuxHostPaths.ts"; +import { validateSelectedAccountHome } from "../accountHomeIsolation.ts"; +import { prepareReviewerHome } from "./reviewerHome.ts"; export const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; /** The prefix of a launch's own folder (its profile and TMPDIR) in the temporary folder. */ @@ -21,6 +23,132 @@ export const BUBBLEWRAP_USERNS_DOCS = "docs/installing-and-security.md#linux-whe const BUBBLEWRAP_PROBE_RETRY_MS = 60_000; export const ISOLATION_NOTE = "CanvasTTY agent isolation: files can be written only inside the project folder, $TMPDIR and this CLI's own folders; SSH/cloud keys, other agents' credentials and CanvasTTY's tokens cannot be read; other processes, apps and daemons are out of reach. \"Operation not permitted\" outside that is this rule: do the work inside the project, or tell the person what you need."; +/** Restricted reviewers keep HOME, TMPDIR and their profile together below CanvasTTY's private run directory. */ +function reviewerRunRoot(userDataPath: string): string { + const root = join(realpathSync(userDataPath), "launch-runs"); + try { mkdirSync(root, { mode: 0o700 }); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + const rootInfo = lstatSync(root); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || realpathSync(root) !== root) { + throw new LaunchRefusal("The private launch-runs folder is not a safe directory. The reviewer was not started."); + } + chmodSync(root, 0o700); + return root; +} + +const REVIEWER_SYSTEM_READ_ROOTS = [ + "/bin", "/usr/bin", "/sbin", "/usr/sbin", "/usr/lib", "/usr/libexec", "/usr/share", + "/System/Library", "/System/Volumes/Preboot/Cryptexes/OS", "/dev", "/private/var/db/dyld" +]; +const MAX_REVIEWER_RUNTIME_FILES = 256; +const MAX_REVIEWER_RUNTIME_DEPTH = 16; + +/** Resolve only the selected runtime's exact Mach-O dependencies; never grant its containing directory. */ +function reviewerRuntimeDependencies(roots: readonly string[], env: Readonly>): string[] { + const runtimeFiles = new Set(); + const queue: Array<{ path: string; executablePath: string; runpaths: string[]; depth: number; selectedCommand: boolean }> = roots + .filter((path) => isAbsolute(path) && existsSync(path)) + .map((path, index) => ({ path, executablePath: realpathSync(path), runpaths: [], depth: 0, selectedCommand: index === 0 })); + const seen = new Set(); + const isSystemFile = (path: string): boolean => REVIEWER_SYSTEM_READ_ROOTS.some((root) => { + const rest = relative(root, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }); + const resolveRunpath = (value: string, loader: string, executable: string): string | null => { + if (value.startsWith("@loader_path/")) return join(dirname(loader), value.slice("@loader_path/".length)); + if (value.startsWith("@executable_path/")) return join(dirname(executable), value.slice("@executable_path/".length)); + return isAbsolute(value) ? value : null; + }; + const runtimePaths = (file: string, executable: string): string[] => { + const result = spawnSync("/usr/bin/otool", ["-l", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) return []; + const lines = (result.stdout ?? "").split(/\r?\n/u); + const paths: string[] = []; + for (let index = 0; index < lines.length; index++) { + if (lines[index].trim() !== "cmd LC_RPATH") continue; + for (let next = index + 1; next < Math.min(lines.length, index + 5); next++) { + const match = /^\s*path\s+(.+?)\s+\(offset\s+\d+\)\s*$/u.exec(lines[next]); + if (!match) continue; + const path = resolveRunpath(match[1], file, executable); + if (path) paths.push(path); + break; + } + } + return paths; + }; + const shebangInterpreter = (file: string): string | null => { + const descriptor = openSync(file, "r"); + let firstLine: string; + try { + const buffer = Buffer.alloc(4096); + const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0); + firstLine = buffer.toString("utf8", 0, bytesRead).split(/\r?\n/u, 1)[0]; + } finally { + closeSync(descriptor); + } + if (!firstLine.startsWith("#!")) return null; + const words = firstLine.slice(2).trim().split(/\s+/u).filter(Boolean); + let interpreter = words.shift(); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command has an invalid interpreter line."); + if (interpreter === "/usr/bin/env") { + if (words[0] === "-S") words.shift(); + if (words[0]?.startsWith("-")) throw new LaunchRefusal("The diff-only reviewer command uses an unsupported env interpreter option."); + const name = words[0]; + if (!name) throw new LaunchRefusal("The diff-only reviewer command has no env interpreter."); + const found = (env.PATH ?? "").split(delimiter).filter(Boolean).map((folder) => join(folder, name)).find((path) => existsSync(path)); + if (!found) throw new LaunchRefusal("The diff-only reviewer interpreter could not be resolved from PATH."); + interpreter = found; + } + if (!isAbsolute(interpreter) || !existsSync(interpreter)) throw new LaunchRefusal("The diff-only reviewer interpreter could not be verified."); + return realpathSync(interpreter); + }; + + while (queue.length > 0) { + const current = queue.shift()!; + const file = realpathSync(current.path); + if (isSystemFile(file)) continue; + if (seen.has(file)) continue; + if (seen.size >= MAX_REVIEWER_RUNTIME_FILES || current.depth > MAX_REVIEWER_RUNTIME_DEPTH) { + throw new LaunchRefusal("The diff-only reviewer runtime has too many linked libraries to verify safely."); + } + seen.add(file); + const stat = statSync(file); + if (!stat.isFile() || stat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime file is not a single-link regular file."); + const linked = spawnSync("/usr/bin/otool", ["-L", file], { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }); + if (linked.error || linked.status !== 0) { + if (!current.selectedCommand) continue; // AgentRuntimeBridge can also hand us exact text hooks and config files. + const interpreter = shebangInterpreter(file); + if (!interpreter) throw new LaunchRefusal("The diff-only reviewer command is not a verifiable macOS runtime."); + queue.push({ path: interpreter, executablePath: interpreter, runpaths: current.runpaths, depth: current.depth + 1, selectedCommand: false }); + continue; + } + const localRunpaths = runtimePaths(file, current.executablePath); + const availableRunpaths = [...new Set([...localRunpaths, ...current.runpaths])]; + const lines = (linked.stdout ?? "").split(/\r?\n/u).slice(1); + for (const line of lines) { + const entry = line.trim(); + if (!entry) continue; + const installName = entry.replace(/\s+\(compatibility version\s+.*$/u, "").replace(/\s+\(current version\s+.*$/u, ""); + const candidates = installName.startsWith("@rpath/") + ? availableRunpaths.map((path) => join(path, installName.slice("@rpath/".length))) + : [resolveRunpath(installName, file, current.executablePath)].filter((path): path is string => Boolean(path)); + const dependency = candidates.find((candidate) => existsSync(candidate)); + if (!dependency) { + if (installName.startsWith("@")) throw new LaunchRefusal("A diff-only reviewer runtime dependency could not be resolved safely."); + continue; // A Mach-O may list a dylib for another architecture that is not installed here. + } + const resolved = realpathSync(dependency); + if (isSystemFile(resolved)) continue; + const dependencyStat = statSync(resolved); + if (!dependencyStat.isFile() || dependencyStat.nlink !== 1) throw new LaunchRefusal("A diff-only reviewer runtime dependency is not a single-link regular file."); + runtimeFiles.add(dependency); + runtimeFiles.add(resolved); + queue.push({ path: resolved, executablePath: current.executablePath, runpaths: availableRunpaths, depth: current.depth + 1, selectedCommand: false }); + } + } + return [...runtimeFiles]; +} + export interface AgentIsolationOptions { /** CanvasTTY's userData folder (its private data is never readable inside the layer). */ userDataPath: string; @@ -48,6 +176,8 @@ export interface AgentIsolationOptions { export interface IsolationDecisionInput { provider: ProviderId; + /** Original task project, including launches in a separate reviewer workspace. */ + cwd?: string; profile: LaunchProfile; /** Not launched by the person: a subagent, or an agent a plugin started. */ delegated: boolean; @@ -75,6 +205,13 @@ export interface IsolationLaunch { env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** Host-derived candidate from the selected Accounts contribution; validated against userData at wrap time. */ + accountHome?: string; + /** Host-derived project/session roots hidden from the diff-only reviewer. */ + deniedReadPaths?: readonly string[]; + /** A diff-only reviewer uses fresh CLI state and narrowly granted runtime files. */ + restrictHomeReads?: boolean; + runtimeReadable?: readonly string[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -83,6 +220,8 @@ export interface WrappedLaunch { command: string; args: string[]; env: Record; + /** Host-owned summary for a safe platform-specific restriction (shown on the session card). */ + isolationReason?: string; /** Removes the launch's own folder (profile and temporary files); call when its process exited or was replaced. */ cleanup(): void; } @@ -100,7 +239,7 @@ export class AgentIsolation { private bubblewrap: string | null | undefined; private readonly linuxHostPaths: LinuxHostPaths; /** The last bubblewrap check: a working one is kept, a failed one is repeated after BUBBLEWRAP_PROBE_RETRY_MS. */ - private bubblewrapCheck: { path: string; failure: string | null; at: number } | null = null; + private readonly isolationChecks = new Map(); constructor(options: AgentIsolationOptions) { this.options = options; @@ -121,7 +260,7 @@ export class AgentIsolation { if (this.platform === "linux") { if (this.bubblewrap === undefined) this.bubblewrap = findOnPath("bwrap", exists); if (!this.bubblewrap) return { reason: "bubblewrap (bwrap) is not installed; install it to isolate agents on Linux." }; - const failure = this.bubblewrapFailure(this.bubblewrap); + const failure = this.cachedProbe(`bwrap:${this.bubblewrap}`, () => (this.options.bubblewrapProbe ?? probeBubblewrap)(this.bubblewrap!)); return failure === null ? { layer: "bubblewrap" } : { reason: `bubblewrap (bwrap) is installed but cannot create its sandbox here (${failure}); Ubuntu 24.04 and later block unprivileged user namespaces through AppArmor. ${BUBBLEWRAP_USERNS_DOCS} says how to allow it.` }; @@ -178,32 +317,81 @@ export class AgentIsolation { folder = null; }; try { - const root = realpathSync(this.options.tempRoot ?? tmpdir()); + const root = launch.restrictHomeReads + ? reviewerRunRoot(this.options.userDataPath) + : realpathSync(this.options.tempRoot ?? tmpdir()); folder = mkdtempSync(join(root, ISOLATION_FOLDER_PREFIX)); chmodSync(folder, 0o700); const temp = join(folder, "tmp"); mkdirSync(temp, { mode: 0o700 }); const cwd = realpathSync(launch.cwd); + // Avoid resolving an executable through a chain of HOME symlinks from inside the reviewer sandbox. The host + // resolves only the selected executable; its file grant already covers this canonical spelling. + const launchCommand = launch.restrictHomeReads ? realpathSync(launch.command) : launch.command; + if (launch.restrictHomeReads && (launch.profile !== "plan" || !launch.deniedReadPaths?.length)) + throw new LaunchRefusal("Restricted reviewer reads require a Plan profile and verified project roots."); + const deniedReadPaths = launch.deniedReadPaths?.map((path) => { + if (!isAbsolute(path)) throw new LaunchRefusal("A reviewer isolation root is not absolute."); + const denied = realpathSync(path); + const within = (path: string, folder: string): boolean => { + const rest = relative(folder, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); + }; + if (!statSync(denied).isDirectory() || denied === dirname(denied) + || within(cwd, denied) || within(denied, cwd)) { + throw new LaunchRefusal("A reviewer isolation root is missing or overlaps its temporary workspace."); + } + return denied; + }); + if (launch.deniedReadPaths && deniedReadPaths?.length !== launch.deniedReadPaths.length) { + throw new LaunchRefusal("A reviewer isolation root could not be verified."); + } + const launchEnvironment = { ...launch.env }; + const accountHome = launch.accountHome === undefined + ? undefined + : validateSelectedAccountHome(this.options.userDataPath, launch.provider, launch.accountHome); + // A reviewer on the worker's model account: a copy of the account's run file in the reviewer's own temp folder + // (CanvasTTY's launch-runs stays unreadable to it). + const accountRunSource = launch.restrictHomeReads && launch.provider === "opencode" + ? accountLaunchRunFile(launchEnvironment.OPENCODE_CONFIG, this.options.userDataPath) : undefined; + let accountRunConfig: string | undefined; + if (accountRunSource) { + accountRunConfig = join(temp, "account-opencode.json"); + copyFileSync(accountRunSource, accountRunConfig, fsConstants.COPYFILE_EXCL); + chmodSync(accountRunConfig, 0o600); + } + if (launch.restrictHomeReads) prepareReviewerHome(launchEnvironment, launch.provider, temp, launch.runtimeReadable ?? [], accountRunConfig); + const reviewerRuntimeFiles = launch.restrictHomeReads && this.platform === "darwin" + ? reviewerRuntimeDependencies([launchCommand, ...(launch.runtimeReadable ?? [])], launchEnvironment) + : []; const paths = isolationPaths({ provider: launch.provider, cwd, sessionTemp: temp, - env: launch.env, + env: launchEnvironment, hostEnvironment: this.hostEnvironment, userDataPath: this.options.userDataPath, sessionId: launch.sessionId, - ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(launch.restrictHomeReads ? { privateRunDirectory: folder } : {}), + ...(deniedReadPaths ? { deniedReadPaths } : {}), + ...(launch.restrictHomeReads ? { restrictHomeReads: true, runtimeReadable: [...reviewerRuntimeFiles, launchCommand, + ...(launch.runtimeReadable ?? [])] } : {}), + ...((launch.grantedPrivate || accountHome) ? { + grantedPrivate: [...(launch.grantedPrivate ?? []), ...(accountHome && !launch.restrictHomeReads ? [accountHome] : [])] + } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. const gitTemplate = join(folder, "git-template"); mkdirSync(gitTemplate, { mode: 0o500 }); // An agent that meets "Operation not permitted" can read why here, instead of trying other ways around it. - const env = { ...launch.env, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: ISOLATION_NOTE }; + const isolationNote = ISOLATION_NOTE; + const env: Record = { ...launchEnvironment, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: isolationNote }; if (available.layer === "seatbelt") { const profilePath = join(folder, "profile.sb"); writeFileSync(profilePath, seatbeltProfile(paths), { mode: 0o600, flag: "wx" }); - return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launch.command, ...launch.args], env, cleanup }; + return { command: this.options.sandboxExecPath ?? SANDBOX_EXEC, args: ["-f", profilePath, launchCommand, ...launch.args], env, + cleanup }; } // bubblewrap mounts only what exists: the CLI's own missing folders are created and a missing protected file // gets a placeholder first (LinuxHostPaths), both undone by cleanup(). @@ -211,7 +399,12 @@ export class AgentIsolation { const kind = (path: string): "file" | "directory" | null => { try { const stat = statSync(path); return stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; } catch { return null; } }; - const args = bubblewrapArguments(paths, { command: launch.command, args: launch.args, cwd, ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}) }, kind); + let command = launchCommand; + let commandArgs = [...launch.args]; + const args = bubblewrapArguments(paths, { + command, args: commandArgs, cwd, + ...(launch.env.XDG_RUNTIME_DIR ? { runtimeDir: launch.env.XDG_RUNTIME_DIR } : {}), + }, kind); const hooks = projectHooks(cwd); const mountPoint = kind(dirname(hooks)) === null && args.includes(hooks); return { @@ -233,13 +426,13 @@ export class AgentIsolation { } /** Null when bubblewrap can start here; cached, so a launch costs one check at most once a minute. */ - private bubblewrapFailure(bwrap: string): string | null { + private cachedProbe(key: string, probe: () => string | null): string | null { const now = (this.options.now ?? Date.now)(); - const last = this.bubblewrapCheck; - if (last && last.path === bwrap && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; + const last = this.isolationChecks.get(key); + if (last && (last.failure === null || now - last.at < BUBBLEWRAP_PROBE_RETRY_MS)) return last.failure; let failure: string | null; - try { failure = (this.options.bubblewrapProbe ?? probeBubblewrap)(bwrap); } catch (error) { failure = error instanceof Error ? error.message : String(error); } - this.bubblewrapCheck = { path: bwrap, failure, at: now }; + try { failure = probe(); } catch (error) { failure = error instanceof Error ? error.message : String(error); } + this.isolationChecks.set(key, { failure, at: now }); return failure; } @@ -285,8 +478,19 @@ function findOnPath(name: string, exists: (path: string) => boolean): string | n return null; } -/** The folder of a session's control grant, from the launch environment (it is readable inside the layer). */ export function controlGrantFolder(env: Readonly>): string | null { const connection = env.CANVASTTY_CONTROL_CONNECTION; return connection ? dirname(connection) : null; } + +/** A model account's OpenCode run file: a single-link regular file inside CanvasTTY's own launch-runs folder. */ +function accountLaunchRunFile(path: string | undefined, userDataPath: string): string | undefined { + if (!path || !isAbsolute(path)) return undefined; + try { + const root = realpathSync(join(userDataPath, "launch-runs")); + const file = realpathSync(path); + const rest = relative(root, file); + const stat = statSync(file); + return rest && !rest.startsWith("..") && !isAbsolute(rest) && stat.isFile() && stat.nlink === 1 ? file : undefined; + } catch { return undefined; } +} diff --git a/src/main/services/isolation/bubblewrap.ts b/src/main/services/isolation/bubblewrap.ts index bd0c47b25..d73b03151 100644 --- a/src/main/services/isolation/bubblewrap.ts +++ b/src/main/services/isolation/bubblewrap.ts @@ -29,8 +29,8 @@ export function bubblewrapArguments( "--die-with-parent", "--unshare-pid", "--unshare-ipc", - "--ro-bind", "/", "/", - "--dev-bind", "/dev", "/dev", + ...(paths.restrictReads ? ["--tmpfs","/",...paths.readableAgain.flatMap(path=>exists(path) ? ["--ro-bind",path,path] : [])] : ["--ro-bind", "/", "/"]), + ...(!paths.restrictReads ? ["--dev-bind", "/dev", "/dev"] : []), "--proc", "/proc" ]; const seen = new Set(); @@ -87,6 +87,8 @@ export function bubblewrapArguments( for (const path of paths.socketFolders) { if (exists(path) === "directory" && !seen.has(path)) args.push("--bind", path, path); } + // Last mount: read exceptions and socket grants must never restore host devices inside a reviewer. + if (paths.restrictReads) args.push("--dev", "/dev"); args.push("--chdir", launch.cwd, "--", launch.command, ...launch.args); return args; } diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5e..2945985f7 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -1,4 +1,4 @@ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, lstatSync, realpathSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import type { ProviderId } from "../../../shared/contracts.ts"; import { AGENT_PROVIDERS } from "../../../shared/contracts.ts"; @@ -30,9 +30,18 @@ export interface IsolationPathInput { socketFolders?: readonly string[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ readOnlyProject?: boolean; + /** Additional host-selected trees that a diff-only reviewer must not read. */ + deniedReadPaths?: readonly string[]; + /** Host-only diff reviewer: hide the rest of HOME while keeping its CLI state and explicit runtime files. */ + restrictHomeReads?: boolean; + /** Exact host-created reviewer run folder; replaces the ordinary session launch-runs grant. */ + privateRunDirectory?: string; + runtimeReadable?: readonly string[]; } export interface IsolationPaths { + /** A diff reviewer reads only supplied files, fresh state, trusted runtime and OS libraries. */ + restrictReads?:boolean; /** Writable folders (subpaths), every spelling. */ writable: string[]; /** Writable single files (with their `.lock` / `.tmp` / backup siblings). */ @@ -144,7 +153,7 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { * token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", "plugin-data", "task-budgets.json", "usage-prices.json", "flow-approvals.json", "session-timeline"] .map((name) => join(userDataPath, name)); } @@ -212,8 +221,36 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; + if (input.privateRunDirectory !== undefined && (!input.restrictHomeReads || !isAbsolute(input.privateRunDirectory))) { + throw new Error("A private reviewer run grant must be an absolute path in restricted mode."); + } + let privateRunDirectory = input.privateRunDirectory; + if (privateRunDirectory) { + const runsRoot = join(realpathSync(input.userDataPath), "launch-runs"); + const rootInfo = lstatSync(runsRoot); + const runInfo = lstatSync(privateRunDirectory); + const canonicalRun = realpathSync(privateRunDirectory); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory() || realpathSync(runsRoot) !== runsRoot + || runInfo.isSymbolicLink() || !runInfo.isDirectory() || canonicalRun !== privateRunDirectory + || dirname(canonicalRun) !== runsRoot) { + throw new Error("A private reviewer run grant must be one exact directory below the canonical launch-runs folder."); + } + privateRunDirectory = canonicalRun; + } + const grants = [...(input.grantedPrivate ?? []), privateRunDirectory ?? join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; const project = input.cwd; + if (input.readOnlyProject) { + const projectPaths = pathSpellings(project); + const otherWritePaths = all([ + ...ownFolders, ...own.files, input.sessionTemp, + join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") + ]); + for (const path of otherWritePaths) { + if (projectPaths.some((root) => isWithin(root, path) || isWithin(path, root))) { + throw new Error(`Writable CLI state ${path} overlaps the read-only project ${project}.`); + } + } + } const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { const defaults = providerFolders(provider, {}, hostHome); @@ -240,10 +277,40 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { if (!overlaps(hidden.spellings)) continue; const withinGrant = candidates.every((candidate) => grantSpellings.some((grant) => isWithin(candidate, grant))); const ownAccount = hidden.path === accountRoot && candidates.every((candidate) => accountSpellings.some((base) => candidate !== base && isWithin(candidate, base))); - if (!withinGrant && !ownAccount) throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); + // A CLI home inside this exact project adds no visibility beyond the project already granted. A moved home in + // the plugin-data parent or a sibling is never enough to reopen that larger private tree. + const insideProject = candidates.every((candidate) => pathSpellings(project).some((base) => isWithin(candidate, base))); + if (!withinGrant && !ownAccount && !(hidden.path === join(input.userDataPath, "plugin-data") && insideProject)) { + throw new Error(`CLI home ${folder} overlaps protected CanvasTTY data.`); + } } } const configSources = input.provider === "opencode" ? openCodeConfigPaths(input.env, input.cwd) : { jsonFiles: [], agentDirectories: [] }; + if (input.restrictHomeReads) { + const forbidden = all([...sensitive, ...privateData, ...(input.deniedReadPaths ?? [])]); + const exceptions = [...ownFolders, ...own.files]; + for (const path of exceptions) { + if (!isAbsolute(path)) throw new Error("A reviewer read exception must be absolute."); + const candidates = pathSpellings(path); + const broad = candidates.some(candidate => pathSpellings(home).concat(pathSpellings(hostHome)) + .some(root => isWithin(root,candidate))); + const overlaps = candidates.some(candidate => forbidden.some(hidden => isWithin(candidate,hidden) || isWithin(hidden,candidate))); + const inGrant = candidates.every(candidate => grantSpellings.some(grant => isWithin(candidate,grant))); + if (broad || overlaps && !inGrant) throw new Error("A reviewer read exception overlaps a protected tree."); + } + // Trusted host executables and bundled hooks may be files in the reviewed repository. Grant exact files only; + // no runtime directory may reopen project contents or another provider's credentials. + const privateForbidden = all([...sensitive, ...others, ...privateData]); + for (const path of input.runtimeReadable ?? []) { + if (!isAbsolute(path)) throw new Error("A reviewer runtime file must be absolute."); + if (!existsSync(path)) continue; // A different provider's optional bundled adapter may be absent. + const stat = statSync(path); + if (!stat.isFile()) throw new Error("A reviewer runtime grant must be a file."); + if (stat.nlink !== 1) throw new Error("A reviewer runtime grant must be a single-link file."); + if (pathSpellings(path).some(candidate => privateForbidden.some(hidden => isWithin(candidate, hidden)))) + throw new Error("A reviewer runtime file overlaps protected data."); + } + } const socketFolders = [ ...Object.entries(input.env) .filter(([name, value]) => /^CANVASTTY_.*_ADDRESS$/u.test(name) && typeof value === "string" && isAbsolute(value)) @@ -255,6 +322,7 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { join(input.userDataPath, "orchestration", "runtime") ]; return { + ...(input.restrictHomeReads ? {restrictReads:true} : {}), writable: all([ ...(input.readOnlyProject ? [] : [project]), input.sessionTemp, @@ -262,7 +330,9 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") ]), writableFiles: all(own.files), - creatableFolders: all([...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder))), + creatableFolders: all([ + ...[...ownFolders, join(home, ".npm"), join(home, ".bun")].flatMap((folder) => ancestorsBelow(home, folder)), + ]), protectedWrites: all([ // A repository that exists keeps its config (hooksPath, fsmonitor, filters run code when the person uses git // later, outside the layer); a new one may be created, which writes its config. @@ -271,15 +341,25 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { ...(input.provider === "claude" ? ownFolders.flatMap((folder) => [join(folder, "settings.json"), join(folder, "settings.local.json")]) : []), ...configSources.jsonFiles ]), - protectedDirectories: all(configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")])), + protectedDirectories: all([ + ...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ]), gitHooks: all([join(project, ".git", "hooks")]), projectRoots: input.readOnlyProject ? [] : all([project]), - unreadable: all([...sensitive, ...others, ...privateData]), - readableAgain: all([...grants, ...movedHomes]), + unreadable: all([ + ...sensitive, ...others, ...privateData, + ...(input.restrictHomeReads ? [home, hostHome] : []), + ...(input.deniedReadPaths ?? []), + ]), + readableAgain: all([...grants, ...movedHomes, + ...(input.restrictHomeReads ? [...ownFolders, ...own.files, input.sessionTemp, input.cwd, + "/bin","/usr/bin","/sbin","/usr/sbin","/usr/lib","/usr/libexec","/usr/share","/System/Library","/System/Library/dyld", + "/System/Volumes/Preboot/Cryptexes/OS","/dev","/private/var/db/dyld","/private/var/select/sh", + join(home,".npm"),join(home,".bun"),join(xdg.cache,"npm"),join(xdg.cache,"bun"),...(input.runtimeReadable ?? [])] : [])]), socketFolders: all(socketFolders), + socketPrefixes: all([dirname(dirname(realish(input.sessionTemp))), "/private/tmp", "/tmp"].map((folder) => join(folder, "ctty-"))) // The temporary folder a launch's own folder lives in (sessionTemp is /ctty-iso-…/tmp), and /tmp: where // CanvasTTY's gateways put their sockets when the userData path is too long for one. - socketPrefixes: all([dirname(dirname(realish(input.sessionTemp))), "/private/tmp", "/tmp"].map((folder) => join(folder, "ctty-"))) }; } diff --git a/src/main/services/isolation/reviewerHome.ts b/src/main/services/isolation/reviewerHome.ts new file mode 100644 index 000000000..ac9ad58f4 --- /dev/null +++ b/src/main/services/isolation/reviewerHome.ts @@ -0,0 +1,61 @@ +import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { basename, isAbsolute, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ProviderId } from "../../../shared/contracts.ts"; +import { LaunchRefusal } from "../launchRefusal.ts"; + +/** A reviewer gets fresh CLI state, never a shared agent-writable home or conversation history. */ +/** + * `keepOpenCodeConfig`: the host-verified run file of the worker's model account (CanvasTTY's own launch-runs + * folder). It holds only the account's provider and model (the key travels in the environment), so a reviewer on + * that account can reach its model; every other OpenCode config source is still dropped. + */ +export function prepareReviewerHome(env:Record,provider:ProviderId,temp:string,runtimeReadable:readonly string[]=[],keepOpenCodeConfig?:string):void { + const openCodeOverlay = env.OPENCODE_CONFIG_CONTENT; + if (provider === "opencode" && openCodeOverlay) { + const plugins = [...new Set(runtimeReadable.filter(path => basename(path) === "opencode-plugin.mjs"))]; + if (plugins.length !== 1 || !isAbsolute(plugins[0]) || !existsSync(plugins[0])) { + throw new LaunchRefusal("The diff-only OpenCode reviewer requires a verified CanvasTTY lifecycle plugin runtime file. The agent was not started."); + } + const pluginPath = realpathSync(plugins[0]); + const pluginStat = statSync(pluginPath); + if (!pluginStat.isFile() || pluginStat.nlink !== 1) { + throw new LaunchRefusal("The diff-only OpenCode lifecycle plugin is not a single-link regular file. The agent was not started."); + } + // The original merged inline config can contain arbitrary plugins, MCP servers, credentials and permissions. + // Keep only the host-verified lifecycle plugin required for CanvasTTY's reviewer hooks. + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({plugin: [pathToFileURL(pluginPath).href]}); + } + const original=env.HOME,home=join(temp,"reviewer-home"),state=join(home,`.${provider}`); + mkdirSync(state,{recursive:true,mode:0o700}); + const authentication=provider==="codex" ? {source:join(env.CODEX_HOME||join(original,".codex"),"auth.json"),name:"auth.json",keys:["OPENAI_API_KEY","tokens","auth_mode","last_refresh"]} + : provider==="claude" ? {source:join(env.CLAUDE_CONFIG_DIR||join(original,".claude"),".credentials.json"),name:".credentials.json",keys:["claudeAiOauth"]} : null; + if(authentication) { + let file:number|undefined; + try { + file=openSync(authentication.source,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK|constants.O_NOCTTY); + const before=fstatSync(file); + if(!before.isFile() || before.nlink!==1 || before.size>1024*1024)throw new Error("Reviewer authentication must be a bounded file without links."); + const buffer=Buffer.alloc(1024*1024+1);let length=0; + while(length1024*1024 || before.size!==after.size || before.mtimeMs!==after.mtimeMs || before.ctimeMs!==after.ctimeMs) + throw new Error("Reviewer authentication changed during preparation."); + const value=JSON.parse(buffer.subarray(0,length).toString("utf8")); + if(!value || typeof value!=="object" || Array.isArray(value))throw new Error("Invalid reviewer authentication."); + const selected=Object.fromEntries(authentication.keys.filter(key=>Object.hasOwn(value,key)).map(key=>[key,value[key]])); + writeFileSync(join(state,authentication.name),JSON.stringify(selected),{mode:0o600,flag:"wx"}); + } catch(error) {if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;} + finally {if(file!==undefined)closeSync(file);} + } + env.HOME=home; + for(const name of ["CODEX_HOME","CLAUDE_CONFIG_DIR","GROK_HOME","HERMES_HOME","KIMI_HOME","OPENCODE_CONFIG_DIR","QWEN_HOME"]) + delete env[name]; + if (provider === "opencode" && keepOpenCodeConfig) env.OPENCODE_CONFIG = keepOpenCodeConfig; + else delete env.OPENCODE_CONFIG; + if (provider !== "opencode" || !openCodeOverlay) delete env.OPENCODE_CONFIG_CONTENT; + env.XDG_CONFIG_HOME=join(home,".config");env.XDG_DATA_HOME=join(home,".local","share"); + env.XDG_STATE_HOME=join(home,".local","state");env.XDG_CACHE_HOME=join(home,".cache"); + if(provider==="codex")env.CODEX_HOME=state; + if(provider==="claude")env.CLAUDE_CONFIG_DIR=state; +} diff --git a/src/main/services/isolation/seatbelt.ts b/src/main/services/isolation/seatbelt.ts index 1342435f0..a470315ac 100644 --- a/src/main/services/isolation/seatbelt.ts +++ b/src/main/services/isolation/seatbelt.ts @@ -12,10 +12,9 @@ import type { IsolationPaths } from "./isolationPaths.ts"; * - no other process may be signalled; no application opened through Launch Services (`open`), no Apple events * (`osascript` driving Terminal), no preference writes through cfprefsd (`defaults write`, which would otherwise * write outside the layer on the agent's behalf); - * - Unix sockets: only DNS (mDNSResponder), syslog, this launch's own temporary folder and CanvasTTY's own - * token-authenticated gateways; no Docker, tmux, SSH agent or other daemon of the person's. + * - Unix sockets: DNS (mDNSResponder), syslog, this launch's temporary folder and CanvasTTY's own gateways. * - * Network and the keychain's services stay as they are: the CLI talks to its provider and reads its own sign-in. + * The CLI keeps its provider network access. Diff-only reviewers receive narrowly scoped read grants. */ export function seatbeltProfile(paths: IsolationPaths): string { const lines: string[] = [ @@ -32,10 +31,23 @@ export function seatbeltProfile(paths: IsolationPaths): string { lines.push(`(allow file-write*${paths.writableFiles.map((path) => ` (regex ${regex(`^${escapeRegex(path)}(\\.[^/]*)?$`)})`).join("")})`); } if (paths.unreadable.length > 0) { - lines.push(`(deny file-read* file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); + lines.push(`(deny${paths.restrictReads ? "" : " file-read*"} file-write*${paths.unreadable.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } + if (paths.restrictReads) { + // Deny regular-file reads globally while allowing narrower exact runtime, OS and diff paths to reopen them. + // An unconditional `(deny file-read*)` wins over every exception in Seatbelt. Native macOS runtimes need directory + // entries while resolving resources, so allow directory vnodes and immediately re-hide HOME and project roots. + lines.push("(deny file-read* (vnode-type REGULAR-FILE))", "(allow file-read-metadata (vnode-type DIRECTORY))", "(allow file-read* (vnode-type DIRECTORY))"); + for (const path of paths.unreadable) lines.push(`(deny file-read* (subpath ${quote(path)}))`); } if (paths.readableAgain.length > 0) { - lines.push(`(allow file-read*${paths.readableAgain.map((path) => ` (subpath ${quote(path)})`).join("")})`); + if (paths.restrictReads) { + // Separate rules make each spelling an independent exception. Combining path predicates in one rule intersects + // them, so a caller opening `/var/...` would miss the grant even when its canonical `/private/var/...` was listed. + for (const path of paths.readableAgain) lines.push(`(allow file-read* (subpath ${quote(path)}))`); + } else { + lines.push(`(allow file-read*${paths.readableAgain.map((path) => ` (subpath ${quote(path)})`).join("")})`); + } } // The CLI's own home that sits in a hidden folder (an account home it was handed): writable again, like its other // folders (a sign-in refresh writes there). Its permission settings are denied again below. diff --git a/src/main/services/providerModels.ts b/src/main/services/providerModels.ts index 48808b7e7..5b911a006 100644 --- a/src/main/services/providerModels.ts +++ b/src/main/services/providerModels.ts @@ -1,4 +1,6 @@ import { execFile } from "node:child_process"; +import {readFile,stat} from "node:fs/promises"; +import {join} from "node:path"; import type { AgentProviderId } from "../../shared/contracts.ts"; import { providerChildProcessLaunch, type ProviderChildProcessLaunch, type ProviderCliRegistry } from "./providerCliRegistry.ts"; @@ -30,16 +32,18 @@ export class ProviderModelCatalog { private readonly now: () => number; private readonly listings = new Map(); private readonly running = new Map>(); + private readonly codexHome:string|undefined; - constructor(registry: Pick, options: { run?: ModelListingRunner; now?: () => number } = {}) { + constructor(registry: Pick, options: { run?: ModelListingRunner; now?: () => number;codexHome?:string } = {}) { this.registry = registry; this.run = options.run ?? runListing; this.now = options.now ?? Date.now; + this.codexHome=options.codexHome; } /** Whether this provider's models can be listed at all. */ lists(provider: AgentProviderId): boolean { - return LISTING_COMMANDS[provider] !== undefined; + return LISTING_COMMANDS[provider] !== undefined || provider==="codex" && this.codexHome!==undefined; } /** The last listing, however old; refreshes it in the background when it is missing or old. */ @@ -55,13 +59,14 @@ export class ProviderModelCatalog { * `fresh` waits for a listing (at most the listing timeout) when none is cached yet. */ async unknownModel(provider: AgentProviderId, model: string, options: { fresh?: boolean } = {}): Promise { - if (!this.lists(provider)) return null; + if (provider!=="opencode") return null; if (options.fresh && !this.listings.has(provider)) await this.refresh(provider); return this.unknownModelCached(provider, model); } /** unknownModel from the cached listing only (never waits; a missing listing allows the model). */ unknownModelCached(provider: AgentProviderId, model: string): string | null { + if(provider!=="opencode")return null; const listing = this.peek(provider); if (!listing || listing.models.includes(model)) return null; const closest = closestModels(model, listing.models); @@ -72,6 +77,18 @@ export class ProviderModelCatalog { /** Lists once at a time per provider; resolves when the listing ended (tests and a warm-up may await it). */ refresh(provider: AgentProviderId): Promise { + if(provider==="codex" && this.codexHome){ + const pending=this.running.get(provider);if(pending)return pending; + const operation=(async()=>{ + try{ + const file=join(this.codexHome!,"models_cache.json");if((await stat(file)).size>MAX_LISTING_BYTES)return; + const value=JSON.parse(await readFile(file,"utf8")); + if(!Array.isArray(value.models))return; + const models=[...new Set(value.models.filter((row:unknown)=>row && typeof row==="object" && "slug" in row && typeof row.slug==="string" && /^[\w.-]{1,160}$/.test(row.slug) && (!("visibility" in row) || row.visibility!=="hide")).map((row:{slug:string})=>row.slug))].slice(0,MAX_MODELS); + if(models.length)this.listings.set(provider,{models,checkedAt:this.now()}); + }catch{ /* Missing CLI cache is unknown, not a guessed model list. */ } + })().finally(()=>this.running.delete(provider));this.running.set(provider,operation);return operation; + } const args = LISTING_COMMANDS[provider]; if (!args) return Promise.resolve(); const current = this.running.get(provider); diff --git a/src/preload/index.ts b/src/preload/index.ts index 788211317..d01127d69 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { BACKLOG_IPC, BACKLOG_EVENTS, type BacklogApi } from "../shared/backlog"; import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { AppSettings, @@ -262,6 +263,7 @@ const api: CanvasTTYApi = { ipcRenderer.sendSync(IPC.canvasNavigationOwnerWheel, { clientX, clientY }); }, setShortcutCaptureActive: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationShortcutCapture, active), + setTerminalEditFocus: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationTerminalEditFocus, active), setPointerBindingState: (input: CanvasNavigationPointerBindingInput) => ( ipcRenderer.send(IPC.canvasNavigationPointerBinding, input) ), @@ -293,6 +295,10 @@ const api: CanvasTTYApi = { resolveGitRisk: (reportId: string, action: "neutralize" | "keep") => ipcRenderer.invoke(IPC.terminalResolveGitRisk, reportId, action), onGitRisk: (listener: (report: GitRiskReport) => void) => subscribe(IPC.terminalGitRisk, listener) }, + backlog: { + ...Object.fromEntries(Object.entries(BACKLOG_IPC).map(([name, channel]) => [name, (...args: unknown[]) => ipcRenderer.invoke(channel, ...args)])), + onTaskBoardChanged:(listener)=>subscribe(BACKLOG_EVENTS.taskBoardChanged,listener) + } as BacklogApi, window: { isMacOS: process.platform === "darwin", platform: process.platform, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a494320a4..1d08eefb0 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -81,7 +81,8 @@ import { isShortcutCaptureTarget, matchesPointerShortcut, matchesShortcut, - shouldKeepNativeKeyboardInput + shouldKeepNativeKeyboardInput, + trackTerminalEditFocus } from "./lib/shortcuts"; import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; @@ -681,9 +682,11 @@ export function App(): React.JSX.Element { cwd: string, role: LaunchRole, launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initialPrompt?: string ): Promise => { - await createSession(provider, profile, cwd, launchPosition ?? undefined, role, launchOptions, environment); + const session = await createSession(provider, profile, cwd, launchPosition ?? undefined, role, launchOptions, environment); + if (initialPrompt) await window.canvasTTY.backlog.sendInstructions(session.id, initialPrompt); setLaunchPosition(null); showToast(provider === "terminal" ? t(settings.locale, "terminalStarted") : `${t(settings.locale, "sessionStarted")}: ${provider}`); }, [createSession, launchPosition, settings.locale, showToast]); @@ -1482,9 +1485,13 @@ export function App(): React.JSX.Element { window.addEventListener("keydown", handleShortcut, true); window.addEventListener("pointerdown", handlePointerShortcut, true); + const stopTerminalFocus = window.canvasTTY.window.isMacOS + ? trackTerminalEditFocus(document, (focused) => window.canvasTTY.canvasNavigation.setTerminalEditFocus(focused)) + : () => undefined; return () => { window.removeEventListener("keydown", handleShortcut, true); window.removeEventListener("pointerdown", handlePointerShortcut, true); + stopTerminalFocus(); }; }, [activeSessionId, fullscreenSessionId, goHome, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, shortcutReferenceOpen, showToast, toggleSessionFullscreen]); diff --git a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx index 3c743b2e6..c7ab926a2 100644 --- a/src/renderer/src/features/launcher/AgentLaunchDialog.tsx +++ b/src/renderer/src/features/launcher/AgentLaunchDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { AgentProviderId, AppSettings, @@ -13,6 +13,7 @@ import { UiIcon } from "../../components/UiIcon"; import { t } from "../../lib/i18n"; import { PROVIDERS } from "../../lib/providers"; import { directoryPathFromClipboard } from "../../lib/directoryPathFromClipboard"; +import { backlogApi } from "../workspace/backlogRendererApi"; import { LaunchOptionsSection } from "./LaunchOptionsSection"; import { autoKind, @@ -59,7 +60,8 @@ interface AgentLaunchDialogProps { cwd: string, role: LaunchRole, launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initialPrompt?: string ): Promise; } @@ -81,6 +83,14 @@ export function AgentLaunchDialog({ const [launchOptions, setLaunchOptions] = useState>({}); const changeLaunchOptions = useCallback((options: Record) => setLaunchOptions(options), []); const [environment, setEnvironment] = useState(null); + const [flowId, setFlowId] = useState(""); + const [flowTask, setFlowTask] = useState(""); + const [flows, setFlows] = useState["flows"]>>["templates"][number]>>([]); + const [flowErrors, setFlowErrors] = useState>([]); + const [flowLoading, setFlowLoading] = useState(false); + const [flowRefresh, setFlowRefresh] = useState(0); + const [flowPreview, setFlowPreview] = useState<{projectRoot:string;flowId:string;instructions:string;digest:string|null} | null>(null); + const backlog = useMemo(() => backlogApi(), []); const changeEnvironment = useCallback((choice: SessionEnvironmentChoice | null) => setEnvironment(choice), []); const locale = settings.locale; @@ -88,11 +98,56 @@ export function AgentLaunchDialog({ if (!provider) return; setProfile(initialProfile(provider, settings, platform)); setRole("agent"); + setFlowId(""); + setFlowTask(""); + setFlows([]); + setFlowErrors([]); setCwd(settings.lastDirectory); setConfirmDanger(false); setError(null); }, [provider, settings.lastDirectory]); + useEffect(() => { + if (!provider || provider === "terminal") return; + let alive = true; + setFlowLoading(true); + void backlog.flows(cwd).then((result) => { + if (!alive) return; + setFlows(result.templates); + setFlowErrors(result.errors); + setFlowId((current) => current && !result.templates.some((flow) => flow.id === current) ? "" : current); + }).catch((reason: unknown) => { + if (!alive) return; + setFlows([]); + setFlowErrors([{ file: "flows", line: 0, message: reason instanceof Error ? reason.message : String(reason) }]); + }).finally(() => { if (alive) setFlowLoading(false); }); + return () => { alive = false; }; + }, [backlog, cwd, flowRefresh, provider]); + + const selectedFlow = flows.find(flow => flow.id === flowId); + useEffect(() => { + setFlowPreview(null); + if (!flowId || selectedFlow?.trusted === true) return; + let alive = true; + void backlog.previewFlow(cwd, flowId).then(preview => { + if (alive) setFlowPreview({ ...preview, projectRoot: cwd, flowId }); + }).catch((reason: unknown) => { + if (alive) setError(reason instanceof Error ? reason.message : String(reason)); + }); + return () => { alive = false; }; + }, [backlog, cwd, flowId, selectedFlow?.digest, selectedFlow?.trusted]); + + const approveFlow = async (): Promise => { + if (!flowPreview?.digest || flowPreview.projectRoot !== cwd || flowPreview.flowId !== flowId) return; + setBusy(true); + try { + await backlog.approveFlow(cwd, flowId, flowPreview.digest); + setFlowRefresh(value => value + 1); + setError(null); + } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); } + finally { setBusy(false); } + }; + useEffect(() => { if (!provider) return; const onKeyDown = (event: KeyboardEvent): void => { @@ -109,7 +164,7 @@ export function AgentLaunchDialog({ const dangerKey = PROVIDERS[provider].dangerKey ?? "confirmLaunch"; // An orchestrator without the endpoint would be a plain session with a // misleading badge, so the launch waits for the explicit enable button. - const endpointMissing = role === "orchestrator" && !settings.agentControlEnabled; + const endpointMissing = (role === "orchestrator" || Boolean(flowId)) && !settings.agentControlEnabled; const enableEndpoint = async (): Promise => { setBusy(true); @@ -147,6 +202,10 @@ export function AgentLaunchDialog({ const submit = async (): Promise => { if (endpointMissing) return; + if (flowId && !flowTask.trim()) { + setError(locale === "ru" ? "Опишите задачу для рабочего процесса." : "Describe the task for this workflow."); + return; + } if (profile === "yolo" && !acknowledged && !confirmDanger) { setConfirmDanger(true); return; @@ -156,8 +215,11 @@ export function AgentLaunchDialog({ setError(null); try { if (profile === "yolo" && !acknowledged && !isTerminal) await onAcknowledge(provider); - await onLaunch(provider, profile, cwd, role, Object.keys(launchOptions).length > 0 ? launchOptions : undefined, - environment ?? undefined); + const initialPrompt = flowId + ? await backlog.redactText((await backlog.flowInstructions(cwd, flowId)).split("{{TASK}}").join(flowTask)) + : undefined; + await onLaunch(provider, profile, cwd, flowId ? "orchestrator" : role, Object.keys(launchOptions).length > 0 ? launchOptions : undefined, + environment ?? undefined, initialPrompt); onClose(); } catch (reason) { setError(reason instanceof Error ? reason.message : t(locale, "launchFailed")); @@ -194,10 +256,47 @@ export function AgentLaunchDialog({ {!isTerminal &&
- +
} + {!isTerminal &&
+ +
+ + +
+ {flowId && <> + +