diff --git a/README.md b/README.md index 5efdb879..9fc3b7ac 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 1b68bdb2..6b7e117b 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 0e8b6e3f..bed8bec6 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/docs/canvastty-plugin.schema.json b/docs/canvastty-plugin.schema.json index 72f2e886..7a8464d5 100644 --- a/docs/canvastty-plugin.schema.json +++ b/docs/canvastty-plugin.schema.json @@ -149,7 +149,7 @@ }, "cardActions": { "description": "Card actions (needs cards:decorate): items in the options menu of matching cards; the host calls canvastty.cards.invoke and shows the answer's message as a toast.", - "type": "array", "minItems": 1, "maxItems": 8, + "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/cardAction" } }, "browserEngine": { "$ref": "#/$defs/browserEngine" } diff --git a/docs/plugins.md b/docs/plugins.md index 0047ad17..cbf86aab 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -255,6 +255,10 @@ An environment is where a card runs: a git worktree, a container, a remote host. `keeps` declares what of CanvasTTY's protection reaches the agent there: `launch` (the launch's arguments and environment reach the agent unchanged, so CanvasTTY's hooks and the profile's per-run settings work), `isolated` (the agent does not run on this computer's files: a container or a remote host) and `confines` (the environment itself confines the agent to the project). Undeclared means no: any profile but normal is refused without `launch`, and the card says that base protection does not reach the agent there. An `isolated` environment is not wrapped in CanvasTTY's agent isolation again (the card names the environment's own boundary); any other runs inside it, with the environment's folder as the project. +Checkpoint restoration also refuses repositories when the current index or saved checkpoint contains submodules: parent-repository snapshots do not preserve dirty or untracked data inside nested repositories. Safeguard and restore that state with Git separately; CanvasTTY does not recursively delete or reset submodules. + +Checkpoint restoration cannot suspend an isolated agent by pausing its local SSH/container wrapper. CanvasTTY refuses restoration for related isolated launches, including after the wrapper exits; that exit does not establish remote workload termination. The host remembers the isolation declaration and project scope used by successful launches in the saved host-only card record, independently of later plugin changes or app restarts. Legacy placed cards without this evidence, and malformed evidence, are treated as unknown isolation scope and refuse restoration. These guards cover tracked host sessions, not untracked external workloads. + CanvasTTY keeps the card, the PTY, the saved record and the restore order; the service answers five host-only requests (surfaces cannot send them): | Request | Params | Answer | Budget | @@ -338,6 +342,12 @@ A service may offer up to 16 `tools` to agents. They appear in the `canvastty_ag ### Session events and plugin-owned cards (`sessions:*`) +`canvastty.activity` is a separate host-only integration for the enabled, native-code-trusted Assistant service installed from a canonical Assistant repository. `sessions:events` does not grant activity access. Tool equality fingerprints sent to that service are host-secret HMAC values scoped to the card and fingerprint kind; they are not plain hashes of tool input or output. Install provenance is checked on each delivery. +OpenCode tool outcomes use its direct `tool.execute.after` hook and terminal `message.part.updated` tool states, correlated with a root-turn `tool.execute.before` call. Duplicate, child-session and obsolete completions are ignored. Shell nonzero exit codes are errors; missing exit status remains unknown. Separately trusted native `after-tool` hooks keep their provider-payload permission: the direct callback supplies `{ input, output }`, and a tool-part completion supplies its provider event. This does not grant `sessions:events` subscribers access to those payloads. +Kimi core hooks report `PostToolUse` and `PostToolUseFailure`; Hermes reports `post_tool_call`, independently of native plugin hooks. Kimi's `tool_output` is an opaque `str(ret)` that can also represent a tool error: its class stays `unknown`, while an explicit failure event reports `error`. It supplies no verified changed-path evidence. Hermes shell-hook `extra` carries the result, explicit outcome status and stable turn ID; result text is never interpreted as a status. + + + A service with `sessions:events` calls `sessions.subscribe` `{ ownedOnly? }` (again after every start). The answer lists the open cards; after that the host sends `canvastty.sessions.event` notifications: ```ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index eefc3536..4ecc1383 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -14,7 +14,11 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { rollupOptions: { - input: resolve("src/main/index.ts") + input: { + index: resolve("src/main/index.ts"), + TerminalOutputHistoryWorker: resolve("src/main/services/TerminalOutputHistoryWorker.ts"), + SessionTimelineIndexWorker: resolve("src/main/services/SessionTimelineIndexWorker.ts") + } } } }, diff --git a/integrations/even-g2/src/main.mjs b/integrations/even-g2/src/main.mjs index eefa1be5..4a345c13 100644 --- a/integrations/even-g2/src/main.mjs +++ b/integrations/even-g2/src/main.mjs @@ -1017,6 +1017,17 @@ async function event(e) { paint(); } } +let attentionNotice = ""; +let attentionId = ""; +function showAttention(data) { + if(data.attention && data.attention.id!==attentionId) { + attentionId=data.attention.id; + attentionNotice=`${data.attention.title}: ${data.attention.kind}`; + notice=attentionNotice; + } else if(!data.attention && notice===attentionNotice) { + notice="";attentionNotice="";attentionId=""; + } +} async function poll() { if (!alive) return; try { @@ -1033,6 +1044,7 @@ async function poll() { "/g2/api/terminal?id=" + encodeURIComponent(selected), ); model.terminal(data); + showAttention(data); } if (native) { const state = { diff --git a/native/canvastty-helper/catalog.json b/native/canvastty-helper/catalog.json index d3ec39a2..739fc5d4 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. isolate: \"worktree\" asks the installed environments plugin for a separate worktree. 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,20 @@ "max" ] }, + "isolate": { + "type": "string", + "enum": [ + "worktree" + ] + }, + "review": { + "type": "boolean" + }, + "reviewModel": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "launchOptions": { "type": "object", "maxProperties": 16, @@ -1095,7 +1109,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 +1121,7 @@ "timeoutSeconds": { "type": "integer", "minimum": 1, - "maximum": 600 + "maximum": 100 } }, "required": [ @@ -1118,7 +1132,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 +1173,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/native/canvastty-helper/hook.go b/native/canvastty-helper/hook.go index 1e122f91..72ef706c 100644 --- a/native/canvastty-helper/hook.go +++ b/native/canvastty-helper/hook.go @@ -5,9 +5,12 @@ package main // always exits 0 with nothing on stdout. import ( + "crypto/sha256" + "encoding/hex" "io" "math" "os" + "strings" "time" ) @@ -31,7 +34,12 @@ func runHook(args []string) int { input = parsed } } - turnID := firstString(field(input, "turn_id"), field(input, "turnId"), field(input, "prompt_id"), field(input, "promptId")) + provider, _ := env(envRuntimeProvider) + var providerTurn any = undefinedValue{} + if provider == "hermes" { + providerTurn = field(field(input, "extra"), "turn_id") + } + turnID := firstString(field(input, "turn_id"), field(input, "turnId"), field(input, "prompt_id"), field(input, "promptId"), providerTurn) threadID := firstString(field(input, "session_id"), field(input, "sessionId"), field(input, "thread_id"), field(input, "threadId"), field(input, "conversation_id"), field(input, "conversationId")) var finalAnswer *string @@ -50,7 +58,8 @@ func runHook(args []string) int { text := boundedText(*finalAnswer, maxAnswerChars) lastAssistantMessage = &text } - reportLifecycle(state, event, turnID, threadID, result, lastAssistantMessage) + toolOutcome := nativeToolOutcome(provider, event, input) + reportLifecycle(state, event, turnID, threadID, result, lastAssistantMessage, toolOutcome) return 0 } @@ -81,7 +90,7 @@ func firstString(values ...any) any { return nil } -func reportLifecycle(state, event string, turnID, threadID any, result *jsObject, lastAssistantMessage *string) bool { +func reportLifecycle(state, event string, turnID, threadID any, result *jsObject, lastAssistantMessage *string, toolOutcome *jsObject) bool { if !isRuntimeState(state) || event == "" || jsLength(event) > 80 { return false } @@ -112,6 +121,9 @@ func reportLifecycle(state, event string, turnID, threadID any, result *jsObject if result != nil { message.set("result", result) } + if toolOutcome != nil { + message.set("toolOutcome", toolOutcome) + } expiresAt := envNumber(envCaptureAnswerExpiresAt) shouldCheck := envEquals(envCaptureAnswer, "1") && isFinite(expiresAt) && expiresAt > nowMs() && provider == "codex" && event == "Stop" && state == "idle" && lastAssistantMessage != nil @@ -127,6 +139,139 @@ func reportLifecycle(state, event string, turnID, threadID any, result *jsObject }) } +func nativeToolOutcome(provider, event string, input any) *jsObject { + if event != "PostToolUse" && !((provider == "claude" || provider == "kimi") && event == "PostToolUseFailure") && !(provider == "hermes" && event == "post_tool_call") { + return nil + } + rawToolName, _ := field(input, "tool_name").(string) + toolName := boundedText(stripToolNameControls(strings.TrimSpace(rawToolName)), 80) + if toolName == "" { + toolName = "unknown" + } + response := field(input, "tool_response") + legacyResponse := !isUndefined(response) + var extra any = undefinedValue{} + if provider == "hermes" && isPlainObject(field(input, "extra")) { + extra = field(input, "extra") + } + if !legacyResponse { + if provider == "kimi" { + if output, ok := field(input, "tool_output").(string); ok { + response = output + } + } else if provider == "hermes" { + response = field(extra, "result") + } + } + resultClass := "unknown" + if provider == "claude" && event == "PostToolUse" { + resultClass = "success" + } else if provider == "claude" && event == "PostToolUseFailure" { + if field(input, "is_interrupt") != true { + resultClass = "error" + } + } else if provider == "kimi" && event == "PostToolUseFailure" { + resultClass = "error" + } else if status, ok := field(extra, "status").(string); provider == "hermes" && !legacyResponse && ok { + switch status { + case "ok": + resultClass = "success" + case "blocked": + resultClass = "denied" + case "error", "timeout": + resultClass = "error" + } + } else { + resultClass = explicitNativeToolResultClass(response) + } + outcome := obj("toolName", toolName, "resultClass", resultClass) + toolInput := field(input, "tool_input") + if !isUndefined(toolInput) { + action := strings.ToLower(toolName) + "\n" + jsonStringify(toolInput) + outcome.set("normalizedActionHash", hashToolOutcome(action)) + } + errorText := firstToolOutcomeString(field(input, "error"), field(input, "tool_error"), field(input, "toolError"), + field(response, "error"), field(response, "tool_error"), field(extra, "error_message")) + if resultClass == "error" && errorText != "" { + outcome.set("errorHash", hashToolOutcome(normalizeToolOutcomeText(boundedText(errorText, 8192)))) + } + // Parity with runtime-protocol.mjs: a hash of the tool's output for non-error results; the text never leaves. + if resultClass != "error" && !isUndefined(response) && response != nil { + text, ok := response.(string) + if !ok { + text = jsonStringify(response) + } + outcome.set("outputHash", hashToolOutcome(normalizeToolOutcomeText(boundedText(text, 16384)))) + } + paths := []any{} + if resultClass == "success" && (strings.EqualFold(strings.TrimSpace(rawToolName), "Edit") || strings.EqualFold(strings.TrimSpace(rawToolName), "Write")) { + if path, ok := field(response, "filePath").(string); ok { + path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/") + if path != "" { + paths = append(paths, hashToolOutcome(path)) + } + } + } + outcome.set("changedPathHashes", paths) + return outcome +} + +func explicitNativeToolResultClass(response any) string { + if !isPlainObject(response) { + return "unknown" + } + if field(response, "denied") == true || field(response, "status") == "denied" || field(response, "resultClass") == "denied" { + return "denied" + } + if field(response, "isError") == true || field(response, "is_error") == true || field(response, "success") == false || + field(response, "status") == "error" || field(response, "status") == "failed" || field(response, "resultClass") == "error" { + return "error" + } + if field(response, "success") == true || field(response, "status") == "success" || field(response, "status") == "completed" || field(response, "resultClass") == "success" { + return "success" + } + exitCode := field(response, "exit_code") + if isUndefined(exitCode) { + exitCode = field(response, "exitCode") + } + if code, ok := exitCode.(float64); ok && !math.IsNaN(code) && !math.IsInf(code, 0) && math.Trunc(code) == code { + if code == 0 { + return "success" + } + return "error" + } + return "unknown" +} + +func firstToolOutcomeString(values ...any) string { + for _, value := range values { + if text, ok := value.(string); ok && text != "" { + return text + } + } + return "" +} + +func stripToolNameControls(value string) string { + var b strings.Builder + for _, r := range value { + if r < 0x20 || r == 0x7f { + continue + } + b.WriteRune(r) + } + return b.String() +} + +func normalizeToolOutcomeText(value string) string { + return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")) +} + +func hashToolOutcome(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + func answerCaptureIsActive(address, terminalSessionID, provider, capability string) bool { request := obj( "v", float64(runtimeProtocolVersion), diff --git a/src/agent-browser/orchestration-catalog.mjs b/src/agent-browser/orchestration-catalog.mjs index ad7b53d2..62c19aa2 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. isolate: "worktree" asks the installed environments plugin for a separate worktree. 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,9 @@ 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] }), + isolate: string({ enum: ["worktree"] }), + review: boolean(), + reviewModel: string({ minLength: 1, maxLength: 200 }), launchOptions }, ["provider", "cwd"] @@ -85,13 +89,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 +107,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 +234,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/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index 024a1376..48be44bf 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node import { + AGENT_RUNTIME_ENV, CAPTURE_ANSWER_ENV, CAPTURE_ANSWER_EXPIRES_AT_ENV, CAPTURE_RESULT_ENV, MAX_ANSWER_CHARS, MAX_HOOK_INPUT_BYTES, MAX_RESULT_CHARS, - RUNTIME_STATES + RUNTIME_STATES, + toolOutcomeFromHook } from "./runtime-protocol.mjs"; import { reportLifecycle } from "./runtime-client.mjs"; @@ -34,11 +36,13 @@ try { } catch { input = null; } +const provider = process.env[AGENT_RUNTIME_ENV.provider]; const turnId = firstString( input?.turn_id, input?.turnId, input?.prompt_id, - input?.promptId + input?.promptId, + provider === "hermes" ? input?.extra?.turn_id : undefined ); // The provider's own conversation id; runtime-client keeps it only in a shape that provider issues. const threadId = firstString( @@ -60,13 +64,15 @@ if (captureResult && finalAnswer !== null) { const lastAssistantMessage = captureAnswer && finalAnswer !== null ? boundedText(finalAnswer, MAX_ANSWER_CHARS) : undefined; +const toolOutcome = toolOutcomeFromHook(provider, event, input); await reportLifecycle({ state, event, turnId, ...(threadId ? { threadId } : {}), ...(result === undefined ? {} : { result }), - ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) + ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }), + ...(toolOutcome === undefined ? {} : { toolOutcome }) }); /** Cuts at the limit without leaving a dangling high surrogate. */ diff --git a/src/agent-runtime/opencode-plugin.mjs b/src/agent-runtime/opencode-plugin.mjs index bd8151b8..b582e035 100644 --- a/src/agent-runtime/opencode-plugin.mjs +++ b/src/agent-runtime/opencode-plugin.mjs @@ -1,12 +1,17 @@ import { reportLifecycle } from "./runtime-client.mjs"; -import { CAPTURE_RESULT_ENV } from "./runtime-protocol.mjs"; +import { CAPTURE_RESULT_ENV, toolOutcomeFromOpenCode, normalizedActionHashFromHook } from "./runtime-protocol.mjs"; import { finalAnswer } from "./opencode-final-answer.mjs"; -import { createOpenCodeDecisions } from "./opencode-decisions.mjs"; +import { createOpenCodeDecisions, guardedCall } 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; +// Retain only correlation, never arguments/output. Calls omitted at the bound cannot produce an uncorrelated outcome. +const toolCalls = new Map(); +const MAX_TRACKED_TOOL_CALLS = 256; 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 ?? ""; @@ -16,6 +21,7 @@ const pluginHooks = parsePluginHooks(process.env.CANVASTTY_PLUGIN_HOOK_SESSION); // Set only for a session whose orchestrator reads its final answer (a subagent): the last reply is then sent with // the turn's end, at most MAX_RESULT_CHARS. const captureResult = process.env[CAPTURE_RESULT_ENV] === "1"; +const trackTools = lifecycleEnabled || captureResult || pluginHooks.some(hook => hook.events.includes("after-tool")); // OpenCode (1.18+) runs EVERY exported function of a plugin module as a plugin and reads its hooks; a helper exported // here broke the whole OpenCode start ("plugin config hook failed", then "n.provider"). Export nothing but the plugin. @@ -24,7 +30,22 @@ export const CanvasTTYLifecycle = async (input) => { // fails the tool call before OpenCode asks anyone. const decisions = createOpenCodeDecisions({ client: input && typeof input === "object" ? input.client : undefined }); return { - ...(decisions.enabled ? { "tool.execute.before": (hookInput, output) => decisions.guard(hookInput, output) } : {}), + ...((decisions.enabled || trackTools) ? { + "tool.execute.before": async (hookInput, output) => { + const sessionId = rootSessionId, turnId = rootTurnId; + if (decisions.enabled) await decisions.guard(hookInput, output); + if (!trackTools || !rootWorking || !turnId || sessionId !== rootSessionId + || turnId !== rootTurnId || hookInput?.sessionID !== sessionId || typeof hookInput.callID !== "string" + || !hookInput.callID || hookInput.callID.length > 256 || typeof hookInput.tool !== "string" + || !hookInput.tool || hookInput.tool.length > 80) return; + if (toolCalls.size < MAX_TRACKED_TOOL_CALLS && !toolCalls.has(hookInput.callID)) { + toolCalls.set(hookInput.callID, { sessionId, turnId, tool: hookInput.tool }); + } + } + } : {}), + ...(trackTools ? { + "tool.execute.after": (hookInput, output) => completeTool(hookInput, output, "tool.execute.after") + } : {}), event: async ({ event }) => lifecycleEvent(event, decisions, input && typeof input === "object" ? input.client : undefined) }; }; @@ -45,8 +66,10 @@ async function lifecycleEvent(event, decisions, client) { if (session.parentID || session.parentId) return; rootSessionId = stringField(session.id, sessionId); rootWorking = false; + rootTurnId = null; + toolCalls.clear(); if (!rootSessionId) return; - if (lifecycleEnabled) { + if (lifecycleEnabled || captureResult) { await reportLifecycle({ state: "idle", event: event.type, threadId: rootSessionId }); } runPluginHooks("session-start", event.type, event); @@ -63,50 +86,84 @@ 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(); toolCalls.clear(); } + 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 }); + toolCalls.clear(); + // 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 } : {}) }); + toolCalls.clear(); + 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 }); + toolCalls.clear(); + 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; - } else if (event.type === "tool.execute.after") { - runPluginHooks("after-tool", event.type, event); + rootTurnId = null; + toolCalls.clear(); + } else if (event.type === "message.part.updated") { + const part = properties.part; + if (part?.type !== "tool" || !["completed", "error"].includes(part.state?.status)) return; + await completeTool({ sessionID: part.sessionID ?? sessionId, callID: part.callID, tool: part.tool, + args: part.state.input }, part.state, event.type, part.state.status, event); } } +async function completeTool(input, output, event, status = "completed", hookPayload = { input, output }) { + const call = toolCalls.get(input?.callID); + if (!call || !rootWorking || call.sessionId !== rootSessionId || call.turnId !== rootTurnId + || input.sessionID !== call.sessionId || input.tool !== call.tool) return; + const toolOutcome = toolOutcomeFromOpenCode(call.tool, input.args, output, status); + if (!toolOutcome) return; + // The permission hook normalizes file/shell inputs before hashing; use the identical shape for equality. + const action = guardedCall(call.tool, input.args); + if (action) toolOutcome.normalizedActionHash = normalizedActionHashFromHook(action.toolName, action.toolInput); + // Delete before awaiting transport: the direct hook and PartUpdated may race for the same completion. + toolCalls.delete(input.callID); + if (lifecycleEnabled || captureResult) await reportLifecycle({ state: "working", event, turnId: call.turnId, toolOutcome }); + // Explicitly trusted native hooks retain their provider payload contract. This is separate from service events. + runPluginHooks("after-tool", event, hookPayload); +} + + function stringField(...values) { return values.find((value) => typeof value === "string" && value.length > 0) ?? null; } diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index 4a4d2a71..a4d0f8d1 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -8,12 +8,13 @@ import { MAX_RUNTIME_MESSAGE_BYTES, normalizeThreadId, RUNTIME_PROTOCOL_VERSION, - RUNTIME_STATES + RUNTIME_STATES, + sanitizeToolOutcome } from "./runtime-protocol.mjs"; const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, threadId, result, lastAssistantMessage, toolOutcome }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -23,6 +24,7 @@ export async function reportLifecycle({ state, event, turnId = null, threadId, r if (!address || !terminalSessionId || !provider || !capabilityToken) return false; const validThreadId = normalizeThreadId(provider, threadId); + const safeToolOutcome = sanitizeToolOutcome(toolOutcome); const message = { v: RUNTIME_PROTOCOL_VERSION, @@ -34,7 +36,8 @@ export async function reportLifecycle({ state, event, turnId = null, threadId, r event, turnId: normalizedId(turnId), ...(validThreadId !== undefined ? { threadId: validThreadId } : {}), - ...(result === undefined ? {} : { result }) + ...(result === undefined ? {} : { result }), + ...(safeToolOutcome === undefined ? {} : { toolOutcome: safeToolOutcome }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); const shouldCheckAnswerGrant = process.env[CAPTURE_ANSWER_ENV] === "1" diff --git a/src/agent-runtime/runtime-protocol.d.mts b/src/agent-runtime/runtime-protocol.d.mts index cdf16b46..87294b4b 100644 --- a/src/agent-runtime/runtime-protocol.d.mts +++ b/src/agent-runtime/runtime-protocol.d.mts @@ -6,6 +6,9 @@ export const CAPTURE_ANSWER_ENV: string; export const CAPTURE_ANSWER_EXPIRES_AT_ENV: string; export const MAX_ANSWER_CHARS: number; export const MAX_HOOK_INPUT_BYTES: number; +export const MAX_TOOL_OUTCOME_NAME_CHARS: number; +export const MAX_TOOL_OUTCOME_ERROR_CHARS: number; +export const MAX_TOOL_OUTCOME_PATHS: number; export const AGENT_RUNTIME_ENV: Readonly<{ address: "CANVASTTY_RUNTIME_ADDRESS"; terminalSessionId: "CANVASTTY_RUNTIME_TERMINAL_SESSION_ID"; @@ -32,6 +35,16 @@ export const MIN_DECIDE_TIMEOUT_MS: number; export const MAX_DECIDE_TIMEOUT_MS: number; export function permissionGateTimings(budgetMs?: number): { budgetMs: number; gatewayMs: number; helperMs: number; hookSeconds: number }; export function helperDeadlineMs(env: Record | undefined): number; +export function toolOutcomeFromHook(provider: string, event: string, input: unknown): { + toolName: string; + resultClass: "success" | "error" | "denied" | "unknown"; + normalizedActionHash?: string; + errorHash?: string; + outputHash?: string; + changedPathHashes: string[]; +} | undefined; +export function normalizedActionHashFromHook(toolName: unknown, toolInput: unknown): string | undefined; +export function sanitizeToolOutcome(value: unknown): ReturnType; export const CLAUDE_HTTP_HOOK: Readonly<{ pathPrefix: string; sessionHeader: string; diff --git a/src/agent-runtime/runtime-protocol.mjs b/src/agent-runtime/runtime-protocol.mjs index 4aad8753..9f0c2881 100644 --- a/src/agent-runtime/runtime-protocol.mjs +++ b/src/agent-runtime/runtime-protocol.mjs @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export const RUNTIME_PROTOCOL_VERSION = 1; export const MAX_RUNTIME_MESSAGE_BYTES = 64 * 1024; export const CAPTURE_RESULT_ENV = "CANVASTTY_RUNTIME_CAPTURE_RESULT"; @@ -10,6 +12,11 @@ export const MAX_ANSWER_CHARS = 4000; // of the wire cap: a large Stop payload must still yield its turn id and the // truncated answer instead of being dropped. export const MAX_HOOK_INPUT_BYTES = 512 * 1024; +export const MAX_TOOL_OUTCOME_NAME_CHARS = 80; +export const MAX_TOOL_OUTCOME_ERROR_CHARS = 8 * 1024; +export const MAX_TOOL_OUTCOME_PATHS = 16; +export const MAX_TOOL_OUTCOME_OUTPUT_CHARS = 16 * 1024; +const TOOL_RESULT_CLASSES = new Set(["success", "error", "denied", "unknown"]); export const AGENT_RUNTIME_ENV = Object.freeze({ address: "CANVASTTY_RUNTIME_ADDRESS", @@ -86,6 +93,189 @@ export function helperDeadlineMs(env) { return permissionGateTimings(typeof raw === "string" && /^\d{1,6}$/.test(raw) ? Number(raw) : undefined).helperMs; } +/** + * Derives the small, non-text summary CanvasTTY may share with trusted plugin services after a real tool + * completion hook. Permission requests and denials are deliberately outside this path. Tool inputs, errors and + * paths are used only as hash inputs and are never returned or persisted. + */ +export function toolOutcomeFromHook(provider, event, input) { + if (!isCompletedToolEvent(provider, event)) return undefined; + const record = isRecord(input) ? input : {}; + const rawToolName = typeof record.tool_name === "string" ? record.tool_name.trim() : ""; + const toolName = boundedOutcomeText(rawToolName.replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) || "unknown"; + const extra = provider === "hermes" && isRecord(record.extra) ? record.extra : {}; + // Provider shell-hook envelopes differ; keep legacy tool_response authoritative when present. + const response = record.tool_response !== undefined ? record.tool_response + : provider === "kimi" && typeof record.tool_output === "string" ? record.tool_output + : provider === "hermes" ? extra.result : undefined; + const error = firstHookString(record.error, record.tool_error, record.toolError, + isRecord(response) ? response.error : undefined, + isRecord(response) ? response.tool_error : undefined, extra.error_message); + let resultClass = "unknown"; + if (provider === "claude" && event === "PostToolUse") resultClass = "success"; + else if (provider === "claude" && event === "PostToolUseFailure") { + resultClass = record.is_interrupt === true ? "unknown" : "error"; + } else if (provider === "kimi" && event === "PostToolUseFailure") resultClass = "error"; + else if (provider === "hermes" && record.tool_response === undefined && typeof extra.status === "string") { + // These are authoritative observer statuses, not words parsed from arbitrary result text. + resultClass = extra.status === "ok" ? "success" : extra.status === "blocked" ? "denied" + : extra.status === "error" || extra.status === "timeout" ? "error" : "unknown"; + } else { + resultClass = explicitToolResultClass(response); + } + + const normalizedActionHash = normalizedActionHashFromHook(rawToolName, record.tool_input); + const errorHash = resultClass === "error" && error !== null + ? sha256(normalizeHashText(error.slice(0, MAX_TOOL_OUTCOME_ERROR_CHARS))) + : undefined; + + // A hash of what the tool printed lets a loop detector tell a shell loop that keeps producing the same output from + // one that is still learning something. The response text is never returned or stored. + const outputHash = resultClass !== "error" ? outputHashFromResponse(response) : undefined; + + const changedPathHashes = []; + if (resultClass === "success" && isFileWriteTool(rawToolName) && isRecord(response)) { + const path = typeof response.filePath === "string" ? normalizePathForHash(response.filePath) : ""; + if (path) changedPathHashes.push(sha256(path)); + } + return { + toolName, + resultClass, + ...(normalizedActionHash ? { normalizedActionHash } : {}), + ...(errorHash ? { errorHash } : {}), + ...(outputHash ? { outputHash } : {}), + changedPathHashes + }; +} + +/** OpenCode's direct after hook and terminal ToolPart states, never the generic event wrapper. */ +export function toolOutcomeFromOpenCode(tool, args, value, status = "completed") { + if (!isRecord(value)) return undefined; // Some failed task calls invoke after(undefined) before their error part. + const metadata = isRecord(value.metadata) ? value.metadata : {}; + let resultClass = status === "error" || value.isError === true ? "error" : "unknown"; + if (resultClass !== "error") { + if (Object.hasOwn(metadata, "exit")) { + resultClass = Number.isInteger(metadata.exit) ? (metadata.exit === 0 ? "success" : "error") : "unknown"; + } else if (!/^(?:bash|shell)$/iu.test(String(tool)) + && (typeof value.output === "string" || Array.isArray(value.content))) resultClass = "success"; + } + let output = typeof value.output === "string" ? value.output.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS) : undefined; + if (output === undefined && Array.isArray(value.content)) { + // MCP after hooks carry content instead of native output. Ignore non-text blocks, and bound work as well as bytes. + output = value.content.slice(0, 16).filter(part => isRecord(part) && part.type === "text" && typeof part.text === "string") + .map(part => part.text.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS)).join("\n").slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS); + } + // Native write/edit metadata is authoritative; input paths alone do not prove a file was changed. + const filePath = typeof metadata.filepath === "string" ? metadata.filepath + : isRecord(metadata.filediff) && typeof metadata.filediff.file === "string" ? metadata.filediff.file : undefined; + return toolOutcomeFromHook("opencode", "tool.execute.after", { + tool_name: tool, tool_input: args, + error: typeof value.error === "string" ? value.error : resultClass === "error" ? output : undefined, + tool_response: { resultClass, ...(output !== undefined ? { output: output.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS) } : {}), + ...(filePath ? { filePath } : {}) } + }); +} + +function outputHashFromResponse(response) { + if (response === undefined || response === null) return undefined; + let text; + if (typeof response === "string") text = response; + else { try { text = JSON.stringify(response); } catch { return undefined; } } + if (typeof text !== "string") return undefined; + let bounded = text.slice(0, MAX_TOOL_OUTCOME_OUTPUT_CHARS); + // Never hash half of a surrogate pair (the native helper's boundedText drops it the same way). + if (/[\uD800-\uDBFF]$/u.test(bounded)) bounded = bounded.slice(0, -1); + return sha256(normalizeHashText(bounded)); +} + +/** Hashes the same hook action shape for both the pretool activity and its completed-tool outcome. */ +export function normalizedActionHashFromHook(rawToolName, toolInput) { + if (toolInput === undefined) return undefined; + const toolName = typeof rawToolName === "string" + ? boundedOutcomeText(rawToolName.trim().replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) || "unknown" + : "unknown"; + let serialized; + try { serialized = JSON.stringify(toolInput); } catch { return undefined; } + if (serialized === undefined) return undefined; + return sha256(`${toolName.toLowerCase()}\n${serialized}`); +} + +/** Strips unexpected fields before the compact summary is allowed onto the authenticated lifecycle wire. */ +export function sanitizeToolOutcome(value) { + if (!isRecord(value)) return undefined; + const resultClass = value.resultClass; + if (!TOOL_RESULT_CLASSES.has(resultClass)) return undefined; + const toolName = typeof value.toolName === "string" + ? boundedOutcomeText(value.toolName.replace(/[\u0000-\u001f\u007f]/gu, ""), MAX_TOOL_OUTCOME_NAME_CHARS) + : "unknown"; + const normalizedActionHash = typeof value.normalizedActionHash === "string" && /^[a-f0-9]{64}$/u.test(value.normalizedActionHash) + ? value.normalizedActionHash : undefined; + const errorHash = resultClass === "error" && typeof value.errorHash === "string" && /^[a-f0-9]{64}$/u.test(value.errorHash) + ? value.errorHash : undefined; + const outputHash = resultClass !== "error" && typeof value.outputHash === "string" && /^[a-f0-9]{64}$/u.test(value.outputHash) + ? value.outputHash : undefined; + const changedPathHashes = Array.isArray(value.changedPathHashes) + ? [...new Set(value.changedPathHashes.filter((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)))].slice(0, MAX_TOOL_OUTCOME_PATHS) + : []; + return { + toolName: toolName || "unknown", + resultClass, + ...(normalizedActionHash ? { normalizedActionHash } : {}), + ...(errorHash ? { errorHash } : {}), + ...(outputHash ? { outputHash } : {}), + changedPathHashes + }; +} + +function isCompletedToolEvent(provider, event) { + if (event === "PostToolUse") return true; + if ((provider === "claude" || provider === "kimi") && event === "PostToolUseFailure") return true; + if (provider === "opencode" && (event === "tool.execute.after" || event === "message.part.updated")) return true; + return provider === "hermes" && event === "post_tool_call"; +} + +function explicitToolResultClass(response) { + if (!isRecord(response)) return "unknown"; + if (response.denied === true || response.status === "denied" || response.resultClass === "denied") return "denied"; + if (response.isError === true || response.is_error === true || response.success === false + || response.status === "error" || response.status === "failed" || response.resultClass === "error") return "error"; + if (response.success === true || response.status === "success" || response.status === "completed" + || response.resultClass === "success") return "success"; + const exitCode = typeof response.exit_code === "number" ? response.exit_code + : typeof response.exitCode === "number" ? response.exitCode : undefined; + if (exitCode !== undefined && Number.isInteger(exitCode)) return exitCode === 0 ? "success" : "error"; + return "unknown"; +} + +function isFileWriteTool(value) { + return typeof value === "string" && /^(?:Edit|Write)$/iu.test(value.trim()); +} + +function normalizePathForHash(value) { + return value.trim().replace(/\\/gu, "/"); +} + +function normalizeHashText(value) { + return value.replace(/\r\n?/gu, "\n").trim(); +} + +function sha256(value) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function firstHookString(...values) { + return values.find((value) => typeof value === "string" && value.length > 0) ?? null; +} + +function boundedOutcomeText(value, limit) { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + +function isRecord(value) { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + // Claude Code's own HTTP hooks (`type: "http"`, measured with 2.1.281) carry the lifecycle events straight to the // gateway's loopback listener: no process per event. Claude fills both headers from the session's environment // (`allowedEnvVars`), so the capability never appears in its argv or in a file. Decision hooks (PreToolUse) stay on diff --git a/src/main/editContextMenu.ts b/src/main/editContextMenu.ts new file mode 100644 index 00000000..ad553d44 --- /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 e2fb63e2..ba5ce0ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,8 +1,28 @@ +import { runShutdownSteps } from "./services/shutdownSteps"; +import { BACKLOG_TERMINAL_IPC } from "../shared/backlog"; +import { AttentionService } from "./services/AttentionService"; +import { SessionReports } from "./services/SessionReports"; +import { collectTerminalHistoryRecoverySnapshots, TerminalOutputHistory } from "./services/TerminalOutputHistory"; +import { actionFromHook } from "./services/safety/baseProtection"; +import { normalizedActionHashFromHook } from "../agent-runtime/runtime-protocol.mjs"; +import { acceptLoopSignal } from "./services/AssistantLoopSignal"; +import { UsagePrices } from "./services/UsagePrices"; +import { ProviderUsageSource } from "./services/ProviderUsageSource"; +import { SessionTimelineService } from "./services/SessionTimelineService"; +import { configuredModel } from "./services/configuredModel"; +import { subagentWorktreeResolver } from "./services/SubagentWorktreeResolver"; +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"; import { ipcMain } from "electron"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { dirname, isAbsolute } from "node:path"; import { EvenG2Controller } from "./services/companion/EvenG2Controller"; import { join } from "node:path"; @@ -92,6 +112,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 +185,11 @@ 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 sessionReports:SessionReports|null=null; +let terminalOutputHistory:TerminalOutputHistory|null=null; let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; @@ -194,6 +220,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 +284,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 +357,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 +383,78 @@ 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 outputHistory=new TerminalOutputHistory(()=>redaction.snapshotForWorker(), () => + collectTerminalHistoryRecoverySnapshots((terminalManager?.listMetadata() ?? []).map(row => row.id), + (id, maxChars) => terminalManager!.readBuffer(id, maxChars)), + // Encrypted crash-recovery copy of the worker's history; its key lives only in this process. + {spillDirectory:join(userDataPath,"terminal-history")}); + terminalOutputHistory=outputHistory; + const outputHistorySeeded=new Set(); + const outputHistoryError = (error: unknown): void => { + if (!shutdownRunning) console.warn(error); + }; + const timeline = new SessionTimelineService(userDataPath,text => redaction.redact(text)); + await timeline.load(); + sessionTimeline=timeline; + const reports=new SessionReports(userDataPath,id=>timeline.report(id),(id,at)=>terminalManager?.setSessionReport(id,at)); + sessionReports=reports; + const reviewUsageSessions=new Map(); + 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 attention=new AttentionService(join(userDataPath,"attention-preferences.json"),text=>redaction.redact(text)); + await attention.load(); + const notifyAttention=(id:string,kind:string)=>{ + const row=terminalManager?.getMetadata(id) ?? terminalManager?.listMetadata().find(row=>!row.parentSessionId && row.taskScope?.id===id);if(!row)return; + const event=attention.publish(row.id,row.title || row.provider,kind); + if(!event || !attention.allows("desktop",event) || !settings.get().attentionNotifications || !Notification.isSupported())return; + const labels:Record=settings.get().locale==="ru" ? {response:"Ждёт ответа",approval:"Нужно разрешение",done:"Закончил",failed:"Ошибка",budget:"Бюджет требует внимания",loop:"Повторяет одно действие"} : {response:"Waiting for your answer",approval:"Approval required",done:"Finished",failed:"Failed",budget:"Task budget needs attention",loop:"Repeating the same action"}; + const notice=new Notification({title:event.title,body:labels[kind]}); + notice.on("click",()=>{mainWindow?.show();mainWindow?.focus();mainWindow?.webContents.send(BACKLOG_TERMINAL_IPC.focusRequested,row.id);});notice.show(); + }; + const taskBoard=new OrchestrationTaskBoard(join(userDataPath,"task-boards")); + const templates=new OrchestrationTemplateService(join(userDataPath,"flow-approvals.json")); + let budgetInputGate:(id:string)=>void=()=>undefined; + let markLoopDetected:(id:string)=>boolean=()=>false; + 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"),{ + onWarning:row=>{notifyAttention(row.rootSessionId,"budget");void timeline.append(row.rootSessionId,"budget","Task reached 80% of its budget").catch(console.warn);}, + onPause:row=>{applyBudgetEnforcement(row);notifyAttention(row.rootSessionId,"budget");void timeline.append(row.rootSessionId,"budget","Task paused at its budget limit",row.reason).catch(console.warn);}, + onChange:applyBudgetEnforcement + }); + await budgets.load(); + flushBudgets=()=>budgets.flush(); + const checkpoints = new GitCheckpoints(text => redaction.redact(text),50,join(userDataPath,"checkpoints.json")); + const checkpointTurns = new Map;signal?:AbortSignal;current:()=>boolean}>(); + const checkpointBeforeTurn = (id: string, signal?: AbortSignal): Promise => { + const row=terminalManager?.getMetadata(id); + if (!row || row.exitCode !== null || row.profile !== "auto" && row.profile !== "yolo") return Promise.resolve(); + if (!terminalManager!.canCaptureCheckpoint(id)) return Promise.resolve(); + let entry=checkpointTurns.get(id); + if (!entry || !entry.current()) { + const created={pending:Promise.resolve(),signal,current:terminalManager!.providerSignalGuard(id,{kind:"lifecycle",state:"working"})}; + created.pending=checkpoints.capture(id,row.cwd,signal).catch(error => { + // Keep the failed attempt until the turn ends or its input generation changes. + // Retrying at PostToolUse could snapshot edits as if they preceded the turn. + const reason=redaction.redact(String(error)); + console.warn("Rollback point unavailable",reason); + void timeline.append(id,"checkpoint","Rollback point unavailable",reason).catch(console.warn); + }); + checkpointTurns.set(id,created);entry=created; + } + return entry.pending; + }; diagnostics.configureRedaction(text => redaction.redact(text)); diagnosticContext = () => ({ servicesReady, @@ -373,7 +476,24 @@ async function initializeServices(ipc: IpcRegistrar): Promise { await pluginManager!.storageSet(pluginId, key, value); broadcastPluginStorageChange(pluginId, key, value); }, - emit: (pluginId, serviceId, event, data) => broadcastPluginServiceEvent({ pluginId, serviceId, event, data }), + emit: (pluginId, serviceId, event, data) => { + let broadcastData = data; + if (event === "loop.detected") { + // Verified by the install record (source repository, enabled, trusted services), not the manifest id. + const accepted = acceptLoopSignal({ + installRecord: id => pluginManager?.installRecord(id) ?? null, + session: id => terminalManager?.getMetadata(id) ?? undefined, + turnEpoch: id => runtimeGateway?.currentTurnEpoch(id) ?? null, + consumeEvidence: (id, evidence, epoch) => pluginSessions?.consumeLoopEvidence(id, evidence, epoch) ?? false, + markLoopDetected: id => markLoopDetected(id) + }, pluginId, serviceId, data, text => redaction.redact(text)); + if (!accepted) return; + notifyAttention(accepted.sessionId,"loop"); + void timeline.append(accepted.sessionId,"loop",accepted.label,accepted.reason,"canvastty-assistant").catch(console.warn); + broadcastData = accepted.data; + } + broadcastPluginServiceEvent({ pluginId, serviceId, event, data: broadcastData }); + }, registerSecrets: (pluginId, values) => redaction.add(`plugin:${pluginId}`, values), secretGet: (pluginId, key) => { if (!pluginSecretsService) throw new Error("Plugin secrets are not ready yet."); @@ -512,16 +632,59 @@ async function initializeServices(ipc: IpcRegistrar): Promise { runtimeGateway = new RuntimeGateway({ runtimeDirectory: lifecycleRuntimeDirectory, windowsHostPath, + lifecycleGuard: (terminalSessionId, signal) => terminalManager?.providerSignalGuard(terminalSessionId, { + kind:"lifecycle",state:signal.state,event:signal.event,...(signal.turnId ? {requestId:signal.turnId} : {}) + }) ?? (()=>false), + beforeLifecycle: async (terminalSessionId, signal, cancellation) => { + const manager=terminalManager; + if (!manager) return false; + const current=manager.providerSignalGuard(terminalSessionId, { + kind: "lifecycle", state: signal.state, event: signal.event, + ...(signal.turnId ? {requestId:signal.turnId} : {}) + }); + if (!current()) return false; + const controller=new AbortController(), abort=():void=>controller.abort(); + cancellation.addEventListener("abort",abort,{once:true}); + if(cancellation.aborted)abort(); + // Only a successful write that changes the captured turn cancels its checkpoint; typing and approval replies do not. + const unobserve=manager.observeInputWrites(terminalSessionId,()=>()=>{if(!current())abort();}); + try { await checkpointBeforeTurn(terminalSessionId,controller.signal);return current(); } + finally {unobserve();cancellation.removeEventListener("abort",abort);} + }, onSignal: (terminalSessionId, signal) => { - terminalManager?.applyProviderSignal(terminalSessionId, { + 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; + 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); + } + if(signal.state !== "working" && signal.state !== "needs_approval")checkpointTurns.delete(terminalSessionId); + // 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 }); + void timeline.append(terminalSessionId,"lifecycle",signal.event ?? signal.state,undefined,"provider-hook").catch(console.warn); + if (signal.toolOutcome) { + pluginSessions?.activity({ + type: "tool-outcome", + sessionId: terminalSessionId, + at: Date.now(), + turnEpoch: signal.turnEpoch, + ...(signal.turnId ? {turnId:signal.turnId} : {}), + toolName: redaction.redact(signal.toolOutcome.toolName), + resultClass: signal.toolOutcome.resultClass, + ...(signal.toolOutcome.normalizedActionHash ? { normalizedActionHash: signal.toolOutcome.normalizedActionHash } : {}), + ...(signal.toolOutcome.errorHash ? { errorHash: signal.toolOutcome.errorHash } : {}), + ...(signal.toolOutcome.outputHash ? { outputHash: signal.toolOutcome.outputHash } : {}), + changedPathHashes: signal.toolOutcome.changedPathHashes + }); + } agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { evenG2?.answer( @@ -533,7 +696,29 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }, onAnswerCaptureRevoked: (terminalSessionId) => evenG2?.clearAnswer(terminalSessionId), - onPermissionRequest: (terminalSessionId, request, signal) => decisionHooks.decide(terminalSessionId, request, signal), + onPermissionRequest: async (terminalSessionId, request, signal) => { + if (terminalManager?.isCheckpointRestoreActive(terminalSessionId)) return {behavior:"deny",message:"Workspace checkpoint restoration is in progress."}; + try {budgetInputGate(terminalSessionId);}catch(error) { + const message=redaction.redact(error instanceof Error ? error.message : "Task budget is paused."); + void timeline.append(terminalSessionId,"budget","Tool blocked by task budget",message).catch(console.warn); + return {behavior:"deny",message}; + } + const current=terminalManager?.providerSignalGuard(terminalSessionId,{kind:"lifecycle",state:"working"}); + await checkpointBeforeTurn(terminalSessionId, signal); + if(!current?.() || terminalManager?.isCheckpointRestoreActive(terminalSessionId))return {behavior:"deny",message:"The agent turn changed while preparing its rollback point."}; + const action=actionFromHook(request.toolName,request.toolInput,request.toolInputPreview); + const detail=redaction.redact(JSON.stringify({kind:action.kind,command:action.command,paths:action.paths})); + void timeline.append(terminalSessionId,action.kind === "shell" ? "command" : action.kind === "edit" ? "file" : "tool",request.toolName,detail,"provider-hook").catch(console.warn); + const decision=await decisionHooks.decide(terminalSessionId,request,signal); + if (decision.behavior !== "none") void timeline.append(terminalSessionId,"decision",decision.behavior,decision.message,"core").catch(console.warn); + const normalizedActionHash=normalizedActionHashFromHook(request.toolName,request.toolInput); + pluginSessions?.activity({type:"pretool",sessionId:terminalSessionId,at:Date.now(),turnEpoch:request.turnEpoch,toolName:redaction.redact(request.toolName), + ...(request.turnId ? {turnId:request.turnId} : {}), + normalizedAction:createHash("sha256").update(`${request.toolName}:${detail}`).digest("hex"), + ...(normalizedActionHash ? {normalizedActionHash} : {}), + ...(decision.behavior!=="none" ? {resultClass:decision.behavior} : {})}); + return decision; + }, // Claude Code's lifecycle hooks go straight to a loopback listener where ClaudeHttpHookPolicy allows it. httpHooks: true }); @@ -573,6 +758,10 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } // Output batches of every session flushed in one task leave as one IPC message. + const reportedSafety = new Map(); + 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); }); @@ -581,6 +770,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // only, and the replay when it is shown again to the renderer only; the // event says which (TerminalDataEvent.audience). if (reachesObservers(payload)) { + if(!shutdownRunning && channel===IPC.terminalData && "data" in payload)void outputHistory.append(payload.id,payload.data,payload.outputOffset).catch(outputHistoryError); agentControl?.observe(channel, payload); evenG2?.observe(channel, payload); pluginSessions?.observe(channel, payload); @@ -595,29 +785,55 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // re-derived state (a restored session, already on screen) or the outcome // of a launch the user just asked for, which is news either way. if (channel === IPC.terminalSession && "session" in payload) { - const { id, status, title, provider } = payload.session; + const { id, status, provider } = payload.session; + const membership=JSON.stringify([payload.session.parentSessionId,payload.session.taskScope]); + if(usageMembership.get(id)!==membership){usageMembership.set(id,membership);scheduleUsageRefresh();} + if(!outputHistorySeeded.has(id)){ + outputHistorySeeded.add(id); + const snapshot=terminalManager?.readBuffer(id); + if(!shutdownRunning && snapshot?.buffer)void outputHistory.append(id,snapshot.buffer,snapshot.outputOffset).catch(outputHistoryError); + } + const oldSafety=reportedSafety.get(id) ?? {}; + const nextSafety={isolation:payload.session.isolation ? JSON.stringify(payload.session.isolation) : undefined,gitRisk:payload.session.gitRisk ? JSON.stringify(payload.session.gitRisk) : undefined}; + if(nextSafety.isolation && nextSafety.isolation!==oldSafety.isolation)void timeline.append(id,"isolation","Effective session protection",nextSafety.isolation,"core").catch(console.warn); + if(nextSafety.gitRisk && nextSafety.gitRisk!==oldSafety.gitRisk)void timeline.append(id,"git-risk","Git settings require attention",nextSafety.gitRisk,"core").catch(console.warn); + reportedSafety.set(id,nextSafety); const previousStatus = notifiedAttentionStatus.get(id); + if (previousStatus !== status) void timeline.append(id,"status",status,undefined,"session-manager").catch(console.warn); if (previousStatus !== status) diagnostics.record(status === "failed" ? "error" : "info", "terminal", "session.state", { id, provider, status, exitCode: payload.session.exitCode }); notifiedAttentionStatus.set(id, status); + if(previousStatus!==status){ + if(status==="working")reports.invalidate(id); + else if(status==="done" || status==="failed" || status==="idle" && previousStatus==="working" && payload.session.turnCompleted)void reports.complete(id).catch(console.warn); + } const failureOrigin = status === "failed" ? terminalManager?.consumeFailureOrigin() ?? null : null; - if ((status === "needs_approval" || status === "failed") - && failureOrigin !== "restore" - && (failureOrigin === "user" || previousStatus !== status) - && settings.get().attentionNotifications - && Notification.isSupported()) { - new Notification({ - title: title || provider, - body: attentionStatusLabel(status, settings.get().locale) - }).show(); + if(failureOrigin!=="restore" && (failureOrigin==="user" || previousStatus!==status)) { + if(status==="needs_approval")notifyAttention(id,"approval"); + else if(status==="idle" && previousStatus==="working")notifyAttention(id,payload.session.turnCompleted ? "done" : "response"); + else if(status==="failed")notifyAttention(id,"failed"); + else if(status==="done" && previousStatus!==undefined)notifyAttention(id,"done"); } } else if (channel === IPC.terminalRemoved && "id" in payload) { + usageMembership.delete(payload.id);scheduleUsageRefresh(); + outputHistorySeeded.delete(payload.id); + if(!shutdownRunning)void outputHistory.remove(payload.id).catch(outputHistoryError); + reports.forget(payload.id); + reviewUsageSessions.delete(payload.id); + for(const [worker,reviewer] of reviewUsageSessions)if(reviewer===payload.id)reviewUsageSessions.delete(worker); diagnostics.record("info", "terminal", "session.closed", { id: payload.id }); notifiedAttentionStatus.delete(payload.id); + reportedSafety.delete(payload.id); + checkpointTurns.delete(payload.id); + forgetOrchestrationSession(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. @@ -632,6 +848,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // Plugin services see card events and control only the cards they start (EP-4). const sessionsForPlugins = new PluginSessions({ terminals: terminalManager, + installRecord:id=>pluginManager?.installRecord(id) ?? null, notify: (pluginId, serviceId, method, params) => pluginServices!.notify(pluginId, serviceId, method, params) }); pluginSessions = sessionsForPlugins; @@ -659,7 +876,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 +896,61 @@ 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(), + currentTurnEpoch: id => runtimeGateway?.currentTurnEpoch(id) ?? null, + budget:budgets, + resolveSubagentEnvironment: subagentWorktreeResolver({ + isGitProject: projectRoot => checkpoints.available(projectRoot), + providers: () => pluginManager!.environmentProviders() + }), + 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,result)=>{if(result.reviewerSessionId)reviewUsageSessions.set(id,result.reviewerSessionId);managedTerminals.setTaskMetadata(id,{reviewRequested:true});void timeline.append(id,"review",result.status,result.notes ?? result.reason,"reviewer").catch(console.warn);} }); + budgetInputGate=id=>agentControlService.assertInputAllowed(id); + markLoopDetected=id=>agentControlService.markLoopDetected(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 reviewer=reviewUsageSessions.get(row.id); + managedTerminals.setObservedUsage(row.id,timeline.usage([row.id],prices),reviewer ? timeline.usage([reviewer],prices) : undefined); + 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 +958,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 +982,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 +1057,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }); await materialService.load(); registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow }); + registerBacklogIpc(ipc,{reports,usagePrices,attention,timeline,checkpoints,board:taskBoard,budgets,flows:templates,taskRoot:id=>agentControlService.taskRoot(id),terminals:managedTerminals,getMainWindow:()=>mainWindow}); registerIpc(ipc, { settings, recheckProviderClis: async () => { @@ -836,6 +1109,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); }, @@ -848,6 +1124,8 @@ async function initializeServices(ipc: IpcRegistrar): Promise { markMainBoot("coreServicesReady"); // The Even G2 companion is the last group: nothing on the first frame needs it. evenG2 = new EvenG2Controller({ + notifications:(channel,id)=>attention.list(channel,id), + loopWarningActive:id=>agentControlService.hasCurrentLoopWarning(id), userDataPath, terminals: terminalManager, localDiscovery: process.platform === "darwin", defaultWorkspace: join(app.getPath("documents"), "CanvasTTY Projects"), @@ -1143,12 +1421,6 @@ async function showStartupFailure(window: BrowserWindow, error: unknown): Promis } } -/** Notification body for the two statuses that deserve the user's attention. */ -function attentionStatusLabel(status: "needs_approval" | "failed", locale: LocaleId): string { - if (status === "needs_approval") return locale === "ru" ? "Требуется подтверждение" : "Needs approval"; - return locale === "ru" ? "Сессия завершилась с ошибкой" : "Session failed"; -} - if (hasSingleInstanceLock) { void app.whenReady() .then(() => { @@ -1214,6 +1486,7 @@ app.on("child-process-gone", (_event, details) => { void IPC.terminalData; async function shutdownServices(): Promise { + disposeBudgetObservers?.();disposeBudgetObservers=null; diagnostics.record("info", "application", "shutdown.started"); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); @@ -1221,8 +1494,10 @@ async function shutdownServices(): Promise { if (updateInterval) clearInterval(updateInterval); for (const request of browserRequests.values()) { clearTimeout(request.timer); request.reject(new Error("App closing")); } browserRequests.clear(); - await evenG2?.close(); - if (terminalManager) await terminalManager.shutdown(); + await runShutdownSteps([ + {name:"Even G2",run:()=>evenG2?.close()}, + {name:"terminal processes",run:()=>terminalManager?.shutdown()}, + ]); // The hung-up PTYs exit while the other services close; quitting waits for them (see waitForProcessExits). const ptyExits = terminalManager?.waitForProcessExits().then((left) => { if (left > 0) console.warn(`CanvasTTY quit with ${left} terminal process(es) that did not exit after SIGKILL.`); @@ -1230,10 +1505,17 @@ async function shutdownServices(): Promise { limitsService?.dispose(); if (agentGateway) await Promise.allSettled([agentGateway.close()]); if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); - if (browserService) await Promise.allSettled([browserService.dispose()]); - if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); - if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); - if (materialService) await Promise.allSettled([materialService.dispose()]); + if(terminalOutputHistory)await terminalOutputHistory.close().catch(console.warn); + terminalOutputHistory=null; + await runShutdownSteps([ + {name:"session reports",run:()=>sessionReports?.flush()}, + {name:"session timeline",run:()=>sessionTimeline?.flush()}, + {name:"budgets",run:async()=>{try{await flushBudgets?.();}finally{flushBudgets=null;}}}, + {name:"browser",run:()=>browserService?.dispose()}, + {name:"plugin services",run:()=>pluginServices?.dispose()}, + {name:"plugins",run:()=>pluginManager?.dispose()}, + {name:"materials",run:()=>materialService?.dispose()} + ]); await ptyExits; diagnostics.record("info", "application", "shutdown.completed"); await diagnostics.flush(); @@ -1264,6 +1546,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 00000000..87d4a7bf --- /dev/null +++ b/src/main/ipc/registerBacklogIpc.ts @@ -0,0 +1,154 @@ +import type { BrowserWindow, IpcMainInvokeEvent } from "electron"; +import { BACKLOG_IPC, BACKLOG_EVENTS, type NotificationPreferences, type UsagePrice } from "../../shared/backlog.ts"; +import type { IpcRegistrar } from "./IpcReadinessGate.ts"; +import type { TerminalManager } from "../services/TerminalManager.ts"; +import type { SessionTimelineService } from "../services/SessionTimelineService.ts"; +import type { GitCheckpoints } from "../services/GitCheckpoints.ts"; + +import type { AttentionService } from "../services/AttentionService.ts"; + +import type { OrchestrationTaskBoard, NewOrchestrationTask } from "../services/OrchestrationTaskBoard.ts"; +import type { OrchestrationBudgetService, OrchestrationBudgetLimits } from "../services/OrchestrationBudgetService.ts"; +import type { OrchestrationTemplateService } from "../services/OrchestrationTemplateService.ts"; + +import type { UsagePrices } from "../services/UsagePrices.ts"; +import type {SessionReports} from "../services/SessionReports.ts"; +interface Dependencies { + reports?:SessionReports; + usagePrices:UsagePrices; + board:OrchestrationTaskBoard; budgets:OrchestrationBudgetService; flows:OrchestrationTemplateService; + taskRoot(id:string):{id:string;cwd:string;startedAt:number}; + attention: AttentionService; + terminals: TerminalManager; timeline: SessionTimelineService; checkpoints: GitCheckpoints; + getMainWindow(): BrowserWindow | null; +} +export function registerBacklogIpc(ipc: IpcRegistrar, deps: Dependencies): void { + const {terminals,timeline,checkpoints} = 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); + await timeline.append(task.id,"task","Person added a board task",row.title);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); + await timeline.append(task.id,"task","Person assigned a board task",row.title);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);await timeline.append(task.id,"task","Person closed a board task",row.title);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);await timeline.append(task.id,"budget","Person changed task limits",JSON.stringify(row.limits));return row; + }); + handle(BACKLOG_IPC.clearBudget,async(id:string)=>{ + const task=root(id),row=await deps.budgets.clearLimits(task.id,task.startedAt);await timeline.append(task.id,"budget","Person removed task limits");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." + }); + await timeline.append(id,"flow","Person saved this layout as a project flow",file);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.notificationPreferences, () => deps.attention.get()); + handle(BACKLOG_IPC.setNotificationPreferences, (value:NotificationPreferences) => deps.attention.set(value)); + handle(BACKLOG_IPC.notifications, () => deps.attention.list("desktop")); + 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); + }); + handle(BACKLOG_IPC.timeline, async (id: string, cursor?: string, limit?: number,filter?:{query?:string;types?:string[];sessionIds?:string[]}) => { + const task=root(id); + if (cursor !== undefined && (typeof cursor !== "string" || cursor.length > 100)) throw new Error("Invalid timeline cursor."); + await timeline.flush(); + const live=terminals.listMetadata().filter(row=>terminals.taskScopeFor(row.id).id===task.id); + const retained=timeline.taskSessions(task.id); + const agents=new Map(retained.map(row=>[row.id,{id:row.id,title:row.title}])); + for(const row of live)agents.set(row.id,{id:row.id,title:terminals.redactSecrets(row.title || row.provider)}); + // A continued root keeps the original task identity, including its older root/budget journal. + const allowed=new Set([task.id,...agents.keys()]); + if(filter) { + if(typeof filter!=="object" || filter.query!==undefined && (typeof filter.query!=="string" || filter.query.length>200) + || filter.types!==undefined && (!Array.isArray(filter.types) || filter.types.length>30 || filter.types.some(type=>typeof type!=="string" || type.length>80)) + || filter.sessionIds!==undefined && (!Array.isArray(filter.sessionIds) || filter.sessionIds.length>100 || filter.sessionIds.some(other=>typeof other!=="string" || !allowed.has(other))))throw new Error("Invalid timeline filter."); + } + const page=await timeline.page(id,cursor,limit,{...filter,sessionIds:filter?.sessionIds ?? [...allowed]}, + {taskId:task.id,legacySessionIds:[task.id,...live.map(row=>row.id)]}); + return {...page,facets:{agents:[...agents.values()],types:[...new Set(retained.flatMap(row=>row.types))].sort()}}; + }); + const subtree = (id?: string) => { + if (!id) return undefined; + const selected = session(id), ids = new Set([id]), rows = terminals.listMetadata(); + const childrenByParent = new Map(); + for (const row of rows) { + if (!row.parentSessionId) continue; + const children = childrenByParent.get(row.parentSessionId) ?? []; + children.push(row.id); + childrenByParent.set(row.parentSessionId, children); + } + const queue = [id]; + for (let cursor = 0; cursor < queue.length; cursor++) { + for (const childId of childrenByParent.get(queue[cursor]!) ?? []) { + if (ids.has(childId)) continue; + ids.add(childId); + queue.push(childId); + } + } + if(!selected.parentSessionId)for(const historicalId of timeline.sessionIds(root(id).id))ids.add(historicalId); + return [...ids]; + }; + handle(BACKLOG_IPC.usagePrices,()=>deps.usagePrices.get()); + handle(BACKLOG_IPC.setUsagePrices,(rows:UsagePrice[])=>deps.usagePrices.set(rows)); + handle(BACKLOG_IPC.usageBreakdown,(id:string|undefined,period:"all"|"day"|"week")=>{ + if(!["all","day","week"].includes(period))throw new Error("Invalid usage period."); + return timeline.breakdown(period,id ? root(id).id : undefined,deps.usagePrices.get()); + }); + handle(BACKLOG_IPC.usage, (id?: string) => timeline.usage(subtree(id),deps.usagePrices.get())); + handle(BACKLOG_IPC.report, (id: string) => {session(id);return deps.reports?.report(id) ?? timeline.report(id);}); + handle(BACKLOG_IPC.checkpoints, async (id: string) => { + const row=session(id); if (!await checkpoints.available(row.cwd)) throw new Error("Rollback points are unavailable: the card folder must be a Git project root."); + return checkpoints.list(id,row.cwd); + }); + handle(BACKLOG_IPC.previewCheckpoint, (id: string, ref: string) => checkpoints.preview(id,session(id).cwd,ref)); + handle(BACKLOG_IPC.restoreCheckpoint, async (id: string, ref: string) => { + session(id); + const result=await terminals.withCheckpointRestore(id,cwd=>checkpoints.restore(id,cwd,ref)); + await timeline.append(id,"checkpoint","Person restored a checkpoint",ref);return result; + }); +} diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 72a6c87e..43db93f9 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; @@ -432,13 +439,13 @@ export function registerIpc(ipcMain: IpcRegistrar, { assertMainRenderer(event, getMainWindow); return pluginCards.decorations(); }); - ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown) => { + ipcMain.handle(IPC.pluginsInvokeCardAction, (event, pluginId: unknown, actionId: unknown, sessionId: unknown, input?: unknown) => { // Only the app window's own card menu invokes actions; plugin surfaces cannot reach this channel. assertMainRenderer(event, getMainWindow); if (typeof pluginId !== "string" || typeof actionId !== "string" || typeof sessionId !== "string") { throw new Error("Card action request is invalid."); } - return pluginCards.invoke(pluginId, actionId, sessionId); + return pluginCards.invoke(pluginId, actionId, sessionId, input); }); ipcMain.handle(IPC.pluginsLaunchFieldOptions, (event, pluginId: unknown, provider: unknown) => { // Only the app's own launcher asks; plugin surfaces cannot reach this channel. diff --git a/src/main/services/AgentControlService.ts b/src/main/services/AgentControlService.ts index 3b35fa78..0e5afb06 100644 --- a/src/main/services/AgentControlService.ts +++ b/src/main/services/AgentControlService.ts @@ -2,6 +2,7 @@ import type { AgentProviderId, CreateSessionRequest, LaunchProfileId, + SessionEnvironmentChoice, SessionMetadata, SessionSnapshot } from "../../shared/contracts.ts"; @@ -15,6 +16,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 +36,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,11 +57,18 @@ 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; + isolate?: "worktree"; + /** Internal-only role: suppresses all agent/plugin tools for a Plan-profile reviewer. */ + readOnlyReview?: boolean; } export interface AgentObservation { sessionId: string; status: SessionSnapshot["status"]; + loopDetected?: boolean; /** Raw terminal tail, capped; capabilities with result \"none\" see nothing. */ output: string; /** Once the process exited: its exit code and the last lines of its screen as plain text, masked. */ @@ -75,6 +93,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 +113,30 @@ export interface AgentControlOptions { limits?: () => DelegationLimits; /** CanvasTTY's isolation layer can contain an agent on this computer now (a "contained" auto needs it). */ containment?: () => boolean; + /** Current host-owned provider turn epoch; loop warnings expire as soon as the turn changes. */ + currentTurnEpoch?: (sessionId: string) => number | null; + /** Persistent task budgets. Only usage from a real provider/timeline source is counted. */ + budget?: Pick; + /** Resolves an explicit or automatic subagent environment using the already trusted environments plugin. */ + resolveSubagentEnvironment?: (request: { + provider: AgentProviderId; + parentSessionId: string; + projectRoot: string; + cwd: string; + liveChildren: number; + isolate?: "worktree"; + }) => SessionEnvironmentChoice | null | Promise; + /** 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 +154,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 { @@ -130,8 +187,23 @@ export class PromptNotDeliveredError extends Error { } export class AgentControlService { + private readonly loopWarnings = new Map(); 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 +223,67 @@ 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.assertSpawnCapacity(parent.id); + const resolveEnvironment = this.options.resolveSubagentEnvironment; + if (!resolveEnvironment) { + if (request.isolate === "worktree") throw new DelegationRefusal("The environments plugin does not provide a worktree for this launch."); + return this.createSubagent(parent, request, cwd.cwd, profile.profile, null, signal); } - 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.`); + let environment: SessionEnvironmentChoice | null | Promise; + try { + environment = resolveEnvironment({ + provider: request.provider, + parentSessionId: parent.id, + projectRoot: taskScope.cwd, + cwd: cwd.cwd, + liveChildren: live, + ...(request.isolate ? { isolate: request.isolate } : {}) + }); + } catch (error) { + throw new DelegationRefusal(`The environments plugin could not prepare this subagent: ${error instanceof Error ? error.message : "unknown error"}`); + } + if (environment && typeof (environment as Promise).then === "function") { + return Promise.resolve(environment).then( + (choice) => this.createSubagent(parent, request, cwd.cwd, profile.profile, choice, signal), + (error: unknown) => { throw new DelegationRefusal(`The environments plugin could not prepare this subagent: ${error instanceof Error ? error.message : "unknown error"}`); } + ); + } + return this.createSubagent(parent, request, cwd.cwd, profile.profile, environment as SessionEnvironmentChoice | null, signal); + } + + private createSubagent( + parent: SessionMetadata, + request: SpawnAgentRequest, + cwd: string, + profile: LaunchProfile, + environment: SessionEnvironmentChoice | null, + signal?: AbortSignal + ): Promise { + if (request.isolate === "worktree" && !environment) { + throw new DelegationRefusal("The environments plugin does not provide a worktree for this launch."); } // A call cancelled before its agent starts launches nothing. if (signal?.aborted) return Promise.reject(spawnCanceled()); - const cascade = children.length; + // An asynchronous environment lookup may race another spawn or a human budget change. + // 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) @@ -186,13 +291,21 @@ export class AgentControlService { ...(request.title !== undefined ? { title: request.title } : {}), role: "subagent", parentSessionId: parent.id, + ...(environment ? { environment } : {}), ...(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.trackReview(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 +313,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 +352,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 +379,63 @@ 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.loopWarnings.delete(sessionId); + 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 +462,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; } } @@ -294,6 +509,7 @@ export class AgentControlService { return { sessionId: session.id, status: session.status, + ...(this.hasCurrentLoopWarning(sessionId) ? {loopDetected:true} : {}), // Masked before the cut (a cut inside a secret would leave a tail no pattern recognizes), over a window // wider than any match rather than the whole scrollback. output: this.redactTail(buffer, maxChars), @@ -326,28 +542,176 @@ 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;} + } + + /** Host-only signal from the trusted loop detector; it never stops a process itself. */ + markLoopDetected(sessionId:string):boolean { + const session=this.requireSession(sessionId); + if(session.role!=="subagent" || session.exitCode!==null)return false; + const turnEpoch=this.options.currentTurnEpoch?.(sessionId); + if(typeof turnEpoch!=="number" || !Number.isSafeInteger(turnEpoch) || turnEpoch<=0)return false; + this.loopWarnings.set(sessionId,{at:Date.now(),turnEpoch}); + return true; + } + + /** Current host loop warning, shared by retry eligibility and companion attention. */ + hasCurrentLoopWarning(sessionId:string):boolean { + const warning=this.loopWarnings.get(sessionId); + if(!warning)return false; + const age=Date.now()-warning.at; + if(!Number.isFinite(age) || age<0 || age>60_000)return false; + return this.options.currentTurnEpoch?.(sessionId)===warning.turnEpoch; + } + + /** Retry is restricted to a failed, observed quiet or recently looping 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 hasWorktreeBadge = session.environment?.kind === "worktree"; + const pluginContext = hasWorktreeBadge ? this.terminals.pluginContext(sessionId) : null; + if (hasWorktreeBadge && pluginContext?.environment?.kind !== "worktree") { + throw new DelegationRefusal("The agent's worktree environment is unavailable; its retry was not started locally."); + } + const failed = (session.exitCode !== null && session.exitCode !== 0) || session.status === "failed"; + if (!failed && !this.retryableQuiet.has(sessionId) && !this.hasCurrentLoopWarning(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, hasWorktreeBadge ? original.cwd : session.cwd); + if ("error" in cwd) throw new DelegationRefusal(cwd.error); + const profile = this.profileFor(parentId, session.provider as AgentProviderId, session.profile); + 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.loopWarnings.delete(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.trackReview(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 +723,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 +745,266 @@ export class AgentControlService { this.terminals.dispose(sessionId); } + /** Restores review ownership without starting a watcher before the next prompt is actually delivered. */ + private trackReview(sessionId: string): void { + this.reviewRequested.add(sessionId); + if (this.reviewInputObservers.has(sessionId)) return; + this.reviewInputObservers.set(sessionId, this.terminals.observeInputWrites(sessionId, (_data, submitted) => { + this.invalidateReview(sessionId); + const generation = this.reviewGeneration(sessionId); + this.retryableQuiet.delete(sessionId); + if (!submitted) { + this.reviews.set(sessionId, { status: "unavailable", reason: "Input has not been submitted as a new worker task.", costUsd: null }); + return; + } + return () => queueMicrotask(() => { + if (this.reviewRequested.has(sessionId) && this.reviewGenerations.get(sessionId) === generation) this.scheduleReview(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 +1026,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 +1147,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/AssistantLoopSignal.ts b/src/main/services/AssistantLoopSignal.ts new file mode 100644 index 00000000..14444bf3 --- /dev/null +++ b/src/main/services/AssistantLoopSignal.ts @@ -0,0 +1,62 @@ +/** + * `loop.detected` from the Assistant plugin marks a subagent as looping, which permits a retry and raises an + * attention notice. The plugin id in a manifest is self-declared, so any GitHub repository could install itself as + * `canvastty-assistant`. The signal is accepted only from the plugin whose install record names the Assistant's own + * repository, that is enabled, and whose services the person trusted as native code. + */ +export const ASSISTANT_PLUGIN_ID = "canvastty-assistant"; +export const ASSISTANT_SERVICE_ID = "assistant"; +/** Canonical install sources (PluginManager.normalizeGithubUrl form) of the first-party Assistant plugin. */ +export const ASSISTANT_PLUGIN_SOURCES: ReadonlySet = new Set([ + "https://github.com/howdeploy/canvastty-plugin-assistant.git", + "https://github.com/BIackFIame/canvastty-plugin-assistant.git" +]); + +export interface PluginInstallRecord { sourceUrl: string; enabled: boolean; nativeCodeTrusted: boolean } +export interface LoopSignalSession { provider: string; exitCode: number | null; status: string; role?: string } +export interface LoopSignalDeps { + installRecord(pluginId: string): PluginInstallRecord | null; + session(sessionId: string): LoopSignalSession | undefined; + turnEpoch(sessionId: string): number | null; + consumeEvidence(sessionId: string, evidenceId: string, turnEpoch: number): boolean; + markLoopDetected(sessionId: string): boolean; +} +export interface AcceptedLoopSignal { + sessionId: string; + label: string; + reason?: string; + /** The event without its single-use host evidence, safe to broadcast to the renderer. */ + data: Record; +} + +export function isInstalledAssistant(record: PluginInstallRecord | null): boolean { + if (!record || !record.enabled || !record.nativeCodeTrusted) return false; + // GitHub owner and repository names are case-insensitive. + const source = record.sourceUrl.toLowerCase(); + return [...ASSISTANT_PLUGIN_SOURCES].some((known) => known.toLowerCase() === source); +} + +/** Returns the accepted signal, or null when it must be ignored. Evidence is consumed only for a verified sender. */ +export function acceptLoopSignal(deps: LoopSignalDeps, pluginId: string, serviceId: string, data: unknown, + redact: (text: string) => string): AcceptedLoopSignal | null { + if (pluginId !== ASSISTANT_PLUGIN_ID || serviceId !== ASSISTANT_SERVICE_ID) return null; + if (!isInstalledAssistant(deps.installRecord(pluginId))) return null; + if (!data || typeof data !== "object" || Array.isArray(data)) return null; + const values = data as Record; + const { sessionId, evidenceId } = values; + if (typeof sessionId !== "string" || typeof evidenceId !== "string") return null; + const session = deps.session(sessionId); + const turnEpoch = deps.turnEpoch(sessionId); + if (!session || session.provider === "terminal" || session.exitCode !== null + || session.status === "done" || session.status === "failed" + || turnEpoch === null || !deps.consumeEvidence(sessionId, evidenceId, turnEpoch)) return null; + if (session.role === "subagent") { + try { if (!deps.markLoopDetected(sessionId)) return null; } catch { return null; } + } + const kind = values.kind; + const label = kind === "repeated-error" ? "Repeated tool errors detected" + : kind === "no-file-progress" || kind === "no-progress" ? "No observed file progress" : "Repeated action detected"; + const reason = typeof values.reason === "string" ? redact(values.reason).replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 160) : undefined; + const { evidenceId: _evidenceId, ...safeData } = values; + return { sessionId, label, ...(reason !== undefined ? { reason } : {}), data: safeData }; +} diff --git a/src/main/services/AttentionService.ts b/src/main/services/AttentionService.ts new file mode 100644 index 00000000..80d831ff --- /dev/null +++ b/src/main/services/AttentionService.ts @@ -0,0 +1,60 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { AttentionEvent, NotificationPreferences, NotificationChannel } from "../../shared/backlog.ts"; + +const defaults = (): NotificationPreferences => ({version:1,channels:{desktop:true,phone:true,glasses:true},quietUntil:null,importantOnly:false,sessionIds:null}); +const important = new Set(["approval","failed","budget","loop"]); +const kinds = new Set(["response","approval","done","failed","budget","loop"]); + +/** Metadata-only attention events. Channel filtering never affects session control or safety decisions. */ +export class AttentionService { + private readonly path: string; + private readonly mask: (text:string)=>string; + private preferences = defaults(); + private readonly events: AttentionEvent[] = []; + private readonly last = new Map(); + private writes: Promise = Promise.resolve(); + constructor(path:string, mask:(text:string)=>string) {this.path=path;this.mask=mask;} + async load(): Promise { + try {const text=await readFile(this.path,"utf8");if(text.length>64_000)throw new Error("Notification preferences are too large.");this.preferences=validate(JSON.parse(text));} + catch(error) {if(!(error && typeof error==="object" && "code" in error && error.code==="ENOENT"))throw error;} + } + get(): NotificationPreferences {return structuredClone(this.preferences);} + async set(value:NotificationPreferences): Promise { + const next=validate(value), serialized=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,serialized,{mode:0o600,flag:"wx"}); + await rename(temporary,this.path);this.preferences=next; + }); + this.writes=operation;await operation;return this.get(); + } + publish(sessionId:string,title:string,kind:string,at=Date.now()): AttentionEvent|null { + if(!kinds.has(kind))throw new Error("Unknown attention event."); + const key=`${sessionId}:${kind}`, previous=this.last.get(key); + // Distinct requests for permission must each remain visible to the person. + if(kind!=="approval" && previous!==undefined && at-previous<10_000)return null; + this.last.set(key,at); + if(this.last.size>2000)this.last.delete(this.last.keys().next().value!); + const event:AttentionEvent={id:randomUUID(),sessionId,title:this.mask(title).replace(/[\u0000-\u001f\u007f]/g,"").slice(0,120),kind:kind as AttentionEvent["kind"],at}; + this.events.push(event);if(this.events.length>500)this.events.shift();return event; + } + allows(channel:NotificationChannel,event:AttentionEvent,now=Date.now()):boolean { + const p=this.preferences; + return p.channels[channel] && !(p.quietUntil!==null && p.quietUntil>now) + && (!p.importantOnly || important.has(event.kind)) && (!p.sessionIds || p.sessionIds.includes(event.sessionId)); + } + list(channel:NotificationChannel,sessionId?:string):AttentionEvent[] { + return this.events.filter(event=>(!sessionId || event.sessionId===sessionId) && this.allows(channel,event)).slice(-100).map(event=>({...event})); + } +} +function validate(value:unknown):NotificationPreferences { + if(!value || typeof value!=="object" || Array.isArray(value))throw new Error("Invalid notification preferences."); + const p=value as NotificationPreferences; + if(p.version!==1 || !p.channels || ["desktop","phone","glasses"].some(channel=>typeof p.channels[channel as NotificationChannel]!=="boolean") + || typeof p.importantOnly!=="boolean" || !(p.quietUntil===null || Number.isSafeInteger(p.quietUntil) && p.quietUntil>=0) + || !(p.sessionIds===null || Array.isArray(p.sessionIds) && p.sessionIds.length<=100 && p.sessionIds.every(id=>typeof id==="string" && id.length>0 && id.length<=160)))throw new Error("Invalid notification preferences."); + return {version:1,channels:{desktop:p.channels.desktop,phone:p.channels.phone,glasses:p.channels.glasses},quietUntil:p.quietUntil,importantOnly:p.importantOnly,sessionIds:p.sessionIds ? [...new Set(p.sessionIds)] : null}; +} diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index 80048276..a8f15c67 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 00000000..99ac3521 --- /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/EnvironmentRegistry.ts b/src/main/services/EnvironmentRegistry.ts index 7fd3c2f8..b5b7ee1b 100644 --- a/src/main/services/EnvironmentRegistry.ts +++ b/src/main/services/EnvironmentRegistry.ts @@ -30,6 +30,7 @@ export interface EnvironmentRegistryDependencies { secret(pluginId: string, key: string): Promise; timeouts?: Partial>; platform?: NodeJS.Platform; + onRetained?(sessionId:string,environment:PersistedEnvironmentRef,reason:string):void; } /** The core never waits longer and never falls back to a local launch when a step runs out. */ @@ -137,6 +138,7 @@ export class EnvironmentRegistry { provider: ProviderId; cwd: string; choice: SessionEnvironmentChoice; + projectRoot?:string; }): Promise { const found = this.lookup(request.choice.pluginId, request.choice.kind); if (!found) return { ok: false, reason: `Environment ${request.choice.kind} from plugin ${request.choice.pluginId} is not available.` }; @@ -145,6 +147,7 @@ export class EnvironmentRegistry { kind: request.choice.kind, provider: request.provider, cwd: request.cwd, + ...(request.projectRoot ? {projectRoot:request.projectRoot} : {}), options: request.choice.options ?? {} }, undefined, { budgetMs: LATE_ANSWER_BUDGET_MS, @@ -297,6 +300,11 @@ export class EnvironmentRegistry { reason: options.reason }, options.timeoutMs); if (!answer.ok) console.warn(`CanvasTTY environment ${environment.label} could not be released: ${answer.reason}`); + else if(isRecord(answer.value) && answer.value.released===false) { + const reason=plainText(answer.value.reason,500) ?? "Unreviewed work was kept."; + console.warn(`CanvasTTY environment ${environment.label} retained: ${reason}`); + this.dependencies.onRetained?.(sessionId,environment,reason); + } } /** `canvastty.environment.describe`: the card badge text; null when the plugin gave none. */ diff --git a/src/main/services/GitCheckpoints.ts b/src/main/services/GitCheckpoints.ts new file mode 100644 index 00000000..f1393db0 --- /dev/null +++ b/src/main/services/GitCheckpoints.ts @@ -0,0 +1,354 @@ +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); + +/** Host-owned packs retain snapshot trees independently of the agent-writable repository refs. */ +export class GitCheckpoints { + private readonly redact: (text:string)=>string; + private readonly retain: number; + private readonly trusted = new Map(); + private loaded = false; + private writes:Promise = Promise.resolve(); + private captures:Promise = Promise.resolve(); + private readonly heldPacks=new Map(); + private readonly storagePath?:string; + constructor(redact: (text: string) => string, retain = 50, storagePath?:string) {this.redact=redact;this.retain=retain;this.storagePath=storagePath;} + private async load():Promise { + if(this.loaded)return; + if(this.storagePath) { + try { + const text=await readFile(this.storagePath,"utf8"); + if(Buffer.byteLength(text)>4*1024*1024)throw new Error("Checkpoint registry is too large."); + const values=JSON.parse(text) as unknown; + if(!Array.isArray(values))throw new Error("Invalid checkpoint registry."); + for(const row of values) { + if(!row || typeof row.id!=="string" || typeof row.cwd!=="string" || typeof row.sessionId!=="string" || !/^[a-f0-9]{40,64}$/.test(row.oid))throw new Error("Invalid checkpoint registry."); + if(row.indexOid!==undefined && !/^[a-f0-9]{40,64}$/u.test(row.indexOid))throw new Error("Invalid checkpoint index object."); + if(row.pack!==undefined && !/^[a-f0-9]{40,64}$/u.test(row.pack))throw new Error("Invalid checkpoint pack."); + this.trusted.set(row.id,{cwd:row.cwd,sessionId:row.sessionId,oid:row.oid,...(row.indexOid ? {indexOid:row.indexOid} : {}),...(row.pack ? {pack:row.pack} : {})}); + } + }catch(error){if((error as NodeJS.ErrnoException).code!=="ENOENT")throw error;} + } + await this.pruneAbandonedPackStaging(); + this.loaded=true; + } + private async pruneAbandonedPackStaging():Promise { + if(!this.storagePath)return; + const root=join(dirname(this.storagePath),"checkpoint-objects"); + const projects=await readdir(root,{withFileTypes:true}).catch(error=>{ + if((error as NodeJS.ErrnoException).code==="ENOENT")return []; + throw error; + }); + for(const project of projects) { + if(!project.isDirectory() || !/^[a-f0-9]{64}$/u.test(project.name))continue; + const directory=join(root,project.name); + for(const entry of await readdir(directory,{withFileTypes:true})) { + const owner=/^\.pending-([1-9][0-9]{0,9})-[A-Za-z0-9]{6}$/u.exec(entry.name); + if(!entry.isDirectory() || !owner)continue; + const pid=Number(owner[1]);if(pid>0x7fffffff)continue; + try {process.kill(pid,0);}catch(error) { + // A reused/live PID or an inaccessible process keeps its staging folder. Only confirmed + // dead owners are removed; no shared packs or another running capture can be swept. + if((error as NodeJS.ErrnoException).code==="ESRCH")await this.removePackStaging(join(directory,entry.name)); + } + } + } + } + private async removePackStaging(path:string):Promise { + await rm(path,{recursive:true,force:true}).catch(error=>{ + // Cleanup failure must not turn an already published valid pack into an unregistered capture. + console.warn("Could not remove unfinished checkpoint files.",(error as NodeJS.ErrnoException).code ?? "unknown error"); + }); + } + private async persist(signal?:AbortSignal):Promise { + if(!this.storagePath)return; + const file=this.storagePath, text=JSON.stringify([...this.trusted].map(([id,row])=>({id,...row}))); + const work=this.writes.catch(()=>undefined).then(async()=>{ + signal?.throwIfAborted(); + await mkdir(dirname(file),{recursive:true,mode:0o700}); + const temp=`${file}.${randomUUID()}.tmp`; + try { + await writeFile(temp,text,{mode:0o600,flag:"wx",signal});signal?.throwIfAborted();await rename(temp,file); + } catch(error) { + await rm(temp,{force:true}).catch(()=>undefined); + throw error; + } + }); + this.writes=work;await work; + } + private prefix(sessionId: string): string { + if (!/^[\w-]{1,100}$/.test(sessionId)) throw new Error("Invalid checkpoint session."); + return `refs/canvastty/${sessionId}/`; + } + private async git(cwd: string, args: string[], signal?:AbortSignal,previewOverflow?:(partial:string)=>string): Promise { + signal?.throwIfAborted(); + 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, signal, 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" : ""}`); + } + try {return (await exec("git", [...safe, "-C", cwd, ...args], {env, signal, timeout: 15_000, maxBuffer: 2 * 1024 * 1024})).stdout.trimEnd();} + catch(error) { + const output=error as {code?:unknown;message?:unknown;stdout?:unknown}; + // Only previews can accept a bounded stdout prefix. Timeouts, stderr overflow and real Git errors fail. + if(previewOverflow && output.code==="ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && output.message==="stdout maxBuffer length exceeded" && typeof output.stdout==="string") { + return previewOverflow(output.stdout.slice(0,output.stdout.lastIndexOf("\n")+1)); + } + throw error; + } + } + async available(cwd: string, signal?:AbortSignal): Promise { + try { return await realpath(await this.git(cwd, ["rev-parse", "--show-toplevel"],signal)) === 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); + } + async capture(sessionId: string, cwd: string, signal?:AbortSignal): Promise { + let commit!:()=>void; + const committed=new Promise(resolve=>{commit=resolve;}); + const work=this.captures.catch(()=>undefined).then(()=>this.captureOne(sessionId,cwd,signal,commit)); + this.captures=work; + if(!signal){await work;return;} + // Hook callers stop waiting at the durable commit point (or cancellation), while rollback/pruning stays + // serialized. An aborted queued job must never begin snapshotting a later, already-running turn. + let abort!:()=>void; + const cancelled=new Promise((_resolve,reject)=>{abort=()=>reject(signal.reason ?? new Error("Checkpoint cancelled."));}); + signal.addEventListener("abort",abort,{once:true}); + if(signal.aborted)abort(); + try {await Promise.race([work,committed,cancelled]);} + finally {signal.removeEventListener("abort",abort);} + } + private async captureOne(sessionId:string,cwd:string,signal?:AbortSignal,committed?:()=>void):Promise { + signal?.throwIfAborted(); + await this.load(); + if (!await this.available(cwd,signal)) { signal?.throwIfAborted(); return; } + const stash = await this.git(cwd, ["stash", "create"],signal); + const hash = stash || await this.git(cwd, ["rev-parse", "HEAD"],signal); + // A stash's second parent stores its index state. A clean checkpoint can itself be a merge commit, + // whose second parent is an unrelated branch and must not be used as the saved index. + const indexOid = stash ? await this.git(cwd, ["rev-parse", "--verify", `${hash}^2`],signal) : hash; + const ref = `${this.prefix(sessionId)}${Date.now()}-${randomUUID()}`; + const root=await realpath(cwd),pack=await this.protectObjects(root,hash,indexOid,signal); + try { + signal?.throwIfAborted(); + await this.git(cwd, ["update-ref", ref, hash],signal); + signal?.throwIfAborted(); + } catch(error) { + await this.git(cwd,["update-ref","-d",ref]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,pack); + throw error; + } + + const previous=new Map([...this.trusted].map(([id,row])=>[id,{...row}])); + this.trusted.set(ref,{cwd:root,sessionId,oid:hash,indexOid,...(pack ? {pack} : {})}); + let pruned:Array<{id:string;row:{cwd:string;sessionId:string;oid:string;indexOid?:string;pack?:string}}> = []; + try { + signal?.throwIfAborted(); + const prefix=this.prefix(sessionId); + const snapshots=[...this.trusted].filter(([id,row])=>id.startsWith(prefix) && row.cwd===root && row.sessionId===sessionId) + .map(([id])=>({id,at:Number(id.slice(prefix.length).split("-",1)[0])})).sort((a,b)=>b.at-a.at); + pruned=snapshots.slice(this.retain).map(({id})=>({id,row:this.trusted.get(id)!})); + for (const old of pruned) this.trusted.delete(old.id); + // Commit the registry before discarding older protected packs. If its atomic replacement fails, + // retain the previous registry and packs so a failed capture cannot destroy the last good checkpoint. + await this.persist(signal); + signal?.throwIfAborted(); + } catch(error) { + this.trusted.clear();for(const [id,row] of previous)this.trusted.set(id,row); + // A cancellation can race atomic registry replacement; restore its previous durable contents as well. + try {if(signal?.aborted)await this.persist();} + finally { + await this.git(cwd,["update-ref","-d",ref]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,pack); + } + throw error; + } + committed?.(); + for(const old of pruned) { + await this.git(cwd,["update-ref","-d",old.id]).catch(()=>undefined); + await this.removePackIfUnreferenced(root,old.row.pack); + } + } + private async removePackIfUnreferenced(cwd:string,pack?:string):Promise { + if(!pack || this.heldPacks.has(`${cwd}\0${pack}`) || [...this.trusted.values()].some(row=>row.cwd===cwd && row.pack===pack))return; + const path=this.packPath(cwd,pack); + if(path)await Promise.all([rm(path,{force:true}),rm(path.slice(0,-5)+".idx",{force:true}),rm(path.slice(0,-5)+".rev",{force:true})]); + } + private holdPack(cwd:string,pack?:string):()=>Promise { + if(!pack)return async()=>undefined; + const key=`${cwd}\0${pack}`;this.heldPacks.set(key,(this.heldPacks.get(key)??0)+1); + let released=false; + return async()=>{ + if(released)return;released=true; + const count=this.heldPacks.get(key)??0; + if(count>1)this.heldPacks.set(key,count-1); + else {this.heldPacks.delete(key);await this.removePackIfUnreferenced(cwd,pack);} + }; + } + async list(sessionId: string, cwd: string): Promise> { + await this.load(); + if (!await this.available(cwd)) return []; + const root=await realpath(cwd); + const prefix=this.prefix(sessionId); + // Only the protected host registry decides which snapshots exist; refs are an additional repository convenience. + return [...this.trusted].filter(([id,row])=>id.startsWith(prefix) && row.cwd===root && row.sessionId===sessionId) + .map(([id])=>({id, at:Number(id.slice(prefix.length).split("-",1)[0]), label:"Before agent turn (tracked files)"})) + .sort((a,b)=>b.at-a.at); + } + private async requireRef(sessionId: string, cwd: string, id: string): Promise { + if (!(await this.list(sessionId, cwd)).some((entry) => entry.id === id)) throw new Error("Checkpoint does not belong to this session."); + const row=this.trusted.get(id)!; + // Import from the host pack even if an agent removed refs, reflogs, and every original Git object. + const pack=row.pack && this.packPath(row.cwd,row.pack); + if(pack)await this.importPack(cwd,pack); + let changed=false; + if(!row.indexOid){row.indexOid=await this.snapshotIndexOid(row.cwd,row.oid);changed=true;} + if(!row.pack && this.storagePath) { + // Upgrade a reachable legacy snapshot before it is used. Already deleted legacy objects remain unavailable. + row.pack=await this.protectObjects(row.cwd,row.oid,row.indexOid);changed=true; + } + if(changed)await this.persist(); + return row.oid; + } + private async importPack(cwd:string,pack:string):Promise { + const pending=exec("git",["--no-replace-objects","-c","core.hooksPath=/dev/null","-C",cwd,"index-pack","--stdin"],{timeout:15000,maxBuffer:1024}); + const source=createReadStream(pack);source.on("error",error=>pending.child.stdin?.destroy(error)); + pending.child.stdin?.on("error",()=>undefined);source.pipe(pending.child.stdin!); + try {await pending;}finally {source.destroy();} + } + private packPath(cwd:string,hash:string):string|undefined { + return this.storagePath ? join(dirname(this.storagePath),"checkpoint-objects",createHash("sha256").update(cwd).digest("hex"),`pack-${hash}.pack`) : undefined; + } + private async snapshotIndexOid(cwd:string,oid:string):Promise { + const subject=await this.git(cwd,["show","-s","--format=%s",oid]); + if(!subject.startsWith("WIP on "))return oid; + const parents=(await this.git(cwd,["rev-list","--parents","-n","1",oid])).split(/\s+/u).slice(1); + if(parents.length===2) { + const indexParents=(await this.git(cwd,["rev-list","--parents","-n","1",parents[1]])).split(/\s+/u).slice(1); + // git stash create makes its index commit a child of the same base as the snapshot commit. + if(indexParents.length===1 && indexParents[0]===parents[0])return parents[1]; + } + return oid; + } + private async protectObjects(cwd:string,oid:string,indexOid:string,signal?:AbortSignal):Promise { + signal?.throwIfAborted(); + if(!this.storagePath)return undefined; + const directory=dirname(this.packPath(cwd,"placeholder")!);await mkdir(directory,{recursive:true,mode:0o700}); + // Pack only the snapshot commit and trees used by restore, rather than cloning the project's entire ancestry. + const objects=new Set([oid,...(await this.git(cwd,["rev-list","--objects","--no-object-names",`${oid}^{tree}`],signal)).split("\n")]); + objects.add(indexOid);for(const object of (await this.git(cwd,["rev-list","--objects","--no-object-names",`${indexOid}^{tree}`],signal)).split("\n"))objects.add(object); + if([...objects].some(object=>!/^[a-f0-9]{40,64}$/u.test(object)))throw new Error("Invalid checkpoint object list."); + // Git can leave tmp_pack/partial index files when interrupted. Keep each invocation in its own + // private staging folder so failure cleanup never removes a pack retained by another checkpoint. + const staging=await mkdtemp(join(directory,`.pending-${process.pid}-`)); + try { + const pending=exec("git",["--no-replace-objects","-c","core.hooksPath=/dev/null","-c","pack.threads=1","-C",cwd,"pack-objects","--compression=1",join(staging,"pack")],{signal,timeout:15000,maxBuffer:1024}); + pending.child.stdin?.on("error",()=>undefined); + pending.child.stdin?.end([...objects].join("\n")+"\n"); + const hash=(await pending).stdout.trim();if(!/^[a-f0-9]{40,64}$/u.test(hash))throw new Error("Invalid checkpoint pack hash."); + // Restore streams the pack to index-pack, which creates its own repository index. Retaining + // Git's staging .idx would duplicate unused data; only the complete pack is published atomically. + signal?.throwIfAborted(); + await rename(join(staging,`pack-${hash}.pack`),this.packPath(cwd,hash)!); + return hash; + } finally {await this.removePackStaging(staging);} + } + async preview(sessionId: string, cwd: string, id: string): Promise<{text: string; changedFiles: string[]}> { + const oid=await this.requireRef(sessionId, cwd, id); + let diffTruncated=false,namesTruncated=false; + const text = await this.git(cwd, ["diff", "--no-ext-diff", "--no-textconv", oid, "--", "."],undefined,partial=>{diffTruncated=true;return partial;}); + const names = await this.git(cwd, ["diff", "--name-only", "--no-ext-diff", "--no-textconv", oid, "--", "."],undefined,partial=>{namesTruncated=true;return partial;}); + const notice=diffTruncated || namesTruncated ? "\n[Preview truncated at the output limit. Restoration still uses the complete saved checkpoint."+(namesTruncated ? " The changed-file list is also truncated." : "")+"]" : ""; + return {text: this.redact(text)+notice, changedFiles: this.redact(names).split("\n").filter(Boolean)}; + } + async restore(sessionId: string, cwd: string, id: string): Promise<{ok: boolean; message?: string}> { + const work=this.captures.catch(()=>undefined).then(()=>this.restoreOne(sessionId,cwd,id)); + this.captures=work.then(()=>undefined,()=>undefined); + return work; + } + private async requireRestorableTrees(cwd:string,worktreeOid:string,indexOid:string):Promise { + const entries=await Promise.all([ + this.git(cwd,["ls-files","--stage","-z"]), + this.git(cwd,["ls-tree","-r","-z",worktreeOid]), + this.git(cwd,["ls-tree","-r","-z",indexOid]) + ]); + // Inspect modes only at NUL-delimited record starts: paths can contain newlines, tabs or "160000". + // The parent snapshot does not retain dirty/untracked nested repository data for a safe recursive restore. + if(entries.some(output=>output.split("\0").some(entry=>entry.startsWith("160000 ")))) { + throw new Error("Submodule contents are not captured by checkpoints. Restoration is unavailable when the current index or saved checkpoint contains submodules; safeguard the nested repositories and restore them with Git separately."); + } + } + private async restoreOne(sessionId:string,cwd:string,id:string):Promise<{ok:boolean;message?:string}> { + const initial=this.trusted.get(id); + let release=this.holdPack(initial?.cwd??cwd,initial?.pack); + try { + const target=await this.requireRef(sessionId, cwd, id); + const row=this.trusted.get(id)!; + if(row.pack!==initial?.pack){await release();release=this.holdPack(row.cwd,row.pack);} + const indexOid=row.indexOid!; + await this.requireRestorableTrees(cwd,target,indexOid); + await this.captureOne(sessionId, cwd); + // Capture may prune this checkpoint at a retention limit of one. Re-import its held private pack + // after capture, immediately before restore, in case Git garbage-collected the earlier import. + const pack=row.pack&&this.packPath(row.cwd,row.pack); + if(pack)await this.importPack(cwd,pack); + // Keep the current index until Git removes tracked paths absent from the target worktree. + // Restoring the index first would turn post-checkpoint additions into untouched untracked files. + await this.git(cwd, ["restore", `--source=${target}`, "--worktree", "--", "."]); + await this.git(cwd, ["restore", `--source=${indexOid}`, "--staged", "--", "."]); + return {ok: true, message: "Tracked files restored; the previous work was saved as another checkpoint."}; + } finally {await release();} + } +} + +/** 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 439370a5..d9608e3e 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 00000000..cdb4da8f --- /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 00000000..7dc904c9 --- /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 00000000..82c4637c --- /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 00000000..0a8eec2e --- /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/PluginCards.ts b/src/main/services/PluginCards.ts index 076397b0..ddf74ada 100644 --- a/src/main/services/PluginCards.ts +++ b/src/main/services/PluginCards.ts @@ -6,6 +6,7 @@ import type { PluginCardTone } from "../../shared/contracts.ts"; import { cardActionMatches } from "../../shared/pluginCardActions.ts"; +import { normalizeCardActionInput, normalizeCardReview } from "./PluginChangeReviews.ts"; import type { PluginSessionSummary } from "./PluginSessions.ts"; /** A trusted plugin service that declared `cardActions` (PluginManager.cardActionProviders). */ @@ -33,6 +34,7 @@ export interface CardActionInvocation { actionId: string; sessionId: string; session: PluginSessionSummary; + input?: Record; } const CARD_ACTION_TIMEOUT_MS = 15_000; @@ -126,7 +128,7 @@ export class PluginCards { } /** The person chose an action on a card. Errors and timeouts come back as an error toast, nothing else. */ - async invoke(pluginId: string, actionId: string, sessionId: string): Promise { + async invoke(pluginId: string, actionId: string, sessionId: string, input?: unknown): Promise { // Action ids are unique within a plugin, whichever of its services declares them. const provider = this.providers() .find((candidate) => candidate.pluginId === pluginId && candidate.actions.some((action) => action.id === actionId)); @@ -136,7 +138,8 @@ export class PluginCards { if (!session) throw new Error("No card has that session id."); if (!cardActionMatches(action.when, session)) throw new Error("That action does not apply to this card."); const timeoutMs = this.deps.timeoutMs ?? CARD_ACTION_TIMEOUT_MS; - const params: CardActionInvocation = { actionId, sessionId, session }; + const normalizedInput = normalizeCardActionInput(input); + const params: CardActionInvocation = { actionId, sessionId, session, ...(normalizedInput ? { input: normalizedInput } : {}) }; let timer: NodeJS.Timeout | undefined; try { const answer = await Promise.race([ @@ -148,7 +151,9 @@ export class PluginCards { const record = answer && typeof answer === "object" && !Array.isArray(answer) ? answer as Record : {}; const tone = TONES.has(record.tone as PluginCardTone) ? record.tone as PluginCardTone : "neutral"; const message = typeof record.message === "string" && record.message.trim() ? this.message(record.message) : undefined; - return { tone, ...(message ? { message } : {}) }; + const actions = new Set(this.providers().filter(row => row.pluginId === pluginId).flatMap(row => row.actions.map(action => action.id))); + const review = record.review === undefined ? undefined : normalizeCardReview(record.review, this.deps.redact, actions); + return { tone, ...(message ? { message } : {}), ...(review ? { review } : {}) }; } catch (error) { return { tone: "error", message: this.message(`${provider.pluginName}: ${error instanceof Error ? error.message : "the action failed."}`) }; } finally { diff --git a/src/main/services/PluginChangeReviews.ts b/src/main/services/PluginChangeReviews.ts new file mode 100644 index 00000000..c56f009a --- /dev/null +++ b/src/main/services/PluginChangeReviews.ts @@ -0,0 +1,88 @@ +import type { PluginChangeReview, PluginChangeReviewFile, PluginReviewTextPage } from "../../shared/contracts.ts"; + +const MAX_REVIEW_BYTES = 512 * 1024; +// A normalized review may repeat selected paths as conflict-resolution keys in its action. +// Conflict metadata consumes review space, keeping generated actions below the 1 MiB transport frame. +const MAX_ACTION_BYTES = 2 * MAX_REVIEW_BYTES; +const MAX_TEXT = 16_384; + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Plugin review data is invalid."); + return value as Record; +} + +function integer(value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Plugin review page is invalid."); + return value as number; +} + +function boundedJson(value: unknown, limit: number): string { + let json: string | undefined; + try { json = JSON.stringify(value); } catch { throw new Error("Card action data must be JSON."); } + if (!json || Buffer.byteLength(json) > limit) throw new Error("Card action data is too large."); + return json; +} + +/** Only the app window can supply this payload. Keep it separate from the host's session identity. */ +export function normalizeCardActionInput(value: unknown): Record | undefined { + if (value === undefined) return undefined; + record(value); + return record(JSON.parse(boundedJson(value, MAX_ACTION_BYTES))); +} + +/** Plugins provide text and declared action ids, never HTML or instructions to execute. */ +export function normalizeCardReview(value: unknown, redact: (text: string) => string, actions: ReadonlySet): PluginChangeReview { + boundedJson(value, MAX_REVIEW_BYTES); + const review = record(value); + const text = (value: unknown, limit = MAX_TEXT): string => { + if (typeof value !== "string") throw new Error("Plugin review text is invalid."); + return redact(value.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/gu, "")).slice(0, limit); + }; + const page = (value: unknown): PluginReviewTextPage => { + const source = record(value); + if (typeof source.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + return { ...(source.label === undefined ? {} : { label: text(source.label, 160) }), text: text(source.text), + startLine: integer(source.startLine), totalLines: integer(source.totalLines), hasMore: source.hasMore }; + }; + const action = (value: unknown): string | undefined => { + if (value === undefined) return undefined; + if (typeof value !== "string" || !actions.has(value)) throw new Error("Plugin review action is not declared by this plugin."); + return value; + }; + if (!Array.isArray(review.groups) || review.groups.length > 24) throw new Error("Plugin review groups are invalid."); + let fileCount = 0; + const groups = review.groups.map(value => { + const group = record(value); + if (typeof group.sessionId !== "string" || !/^[\w-]{1,160}$/u.test(group.sessionId)) throw new Error("Plugin review session is invalid."); + if (!Array.isArray(group.files) || (fileCount += group.files.length) > 400) throw new Error("Plugin review files are invalid."); + const files = group.files.map(value => { + const file = record(value); + if (typeof file.path !== "string" || !file.path || file.path.length > 1000 || /[\u0000-\u001F\u007F]/u.test(file.path)) throw new Error("Plugin review file path is invalid."); + if (redact(file.path) !== file.path) throw new Error("Plugin review file names contain protected data."); + const result: PluginChangeReviewFile = { path: file.path, diff: text(file.diff) }; + if (file.status !== undefined) result.status = text(file.status, 80); + if (file.page !== undefined) result.page = integer(file.page); + if (file.hasMore !== undefined) { + if (typeof file.hasMore !== "boolean") throw new Error("Plugin review page is invalid."); + result.hasMore = file.hasMore; + } + result.truncated = file.truncated === true || (typeof file.diff === "string" && file.diff.length > MAX_TEXT); + if (file.conflict !== undefined) { + const conflict = record(file.conflict); + result.conflict = { current: page(conflict.current), agent: page(conflict.agent) }; + if (result.conflict.current.text.length === MAX_TEXT || result.conflict.agent.text.length === MAX_TEXT) result.truncated = true; + } + return result; + }); + return { sessionId: group.sessionId, title: text(group.title, 200), files, + ...(group.error !== undefined ? { error: text(group.error, 2000) } : {}) }; + }); + const acceptActionId = action(review.acceptActionId), rejectActionId = action(review.rejectActionId); + const result: PluginChangeReview = { + title: text(review.title, 200), groups, + ...(acceptActionId ? { acceptActionId } : {}), ...(rejectActionId ? { rejectActionId } : {}), + ...(review.nextOffset !== undefined ? { nextOffset: integer(review.nextOffset) } : {}) + }; + boundedJson(result, MAX_REVIEW_BYTES); + return result; +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553a..b56cf53b 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 { @@ -326,6 +327,12 @@ export class PluginManager { return this.list(); } + /** The host's install record for one plugin: where it was installed from and what the person trusted. */ + installRecord(pluginId: string): { sourceUrl: string; enabled: boolean; nativeCodeTrusted: boolean } | null { + const plugin = this.plugins.get(pluginId); + return plugin ? { sourceUrl: plugin.sourceUrl, enabled: plugin.enabled, nativeCodeTrusted: plugin.nativeCodeTrusted } : null; + } + list(): InstalledPlugin[] { return [...this.plugins.values()] .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name)) @@ -597,6 +604,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") }); } @@ -1801,7 +1809,7 @@ function validateBrowserEngine(value: unknown): PluginBrowserEngine { return { id, title, ...(description ? { description } : {}), layout: value.layout === true }; } -const MAX_CARD_ACTIONS = 8; +const MAX_CARD_ACTIONS = 16; function validateCardActions(value: unknown): PluginCardAction[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_CARD_ACTIONS) { diff --git a/src/main/services/PluginSessions.ts b/src/main/services/PluginSessions.ts index c7d18bba..d919a72e 100644 --- a/src/main/services/PluginSessions.ts +++ b/src/main/services/PluginSessions.ts @@ -12,6 +12,8 @@ import type { TerminalBufferSnapshot, TerminalDataEvent } from "../../shared/contracts.ts"; +import { createHmac, randomBytes } from "node:crypto"; +import { ASSISTANT_PLUGIN_ID, ASSISTANT_SERVICE_ID, isInstalledAssistant, type PluginInstallRecord } from "./AssistantLoopSignal.ts"; import { IPC } from "../../shared/contracts.ts"; import type { PersistedEnvironmentRef } from "./TerminalSessionStore.ts"; @@ -65,8 +67,16 @@ interface TerminalPort { export interface PluginSessionsDependencies { terminals: TerminalPort; + /** Live host install provenance; absent or untrusted records never receive activity. */ + installRecord?(pluginId:string):PluginInstallRecord|null; /** Sends a notification to a running service; false when it is not running. */ - notify(pluginId: string, serviceId: string, method: "canvastty.sessions.event", params: PluginSessionEvent): boolean; + notify(pluginId: string, serviceId: string, method: "canvastty.sessions.event" | "canvastty.activity", params: PluginSessionEvent | PluginActivity): boolean; +} +export interface PluginActivity { + type: string; sessionId: string; at: number; turnId?: string; turnEpoch?: number; evidenceId?: string; toolName?: string; normalizedAction?: string; + normalizedActionHash?: string; errorHash?: string; outputHash?: string; changedPathHashes?: string[]; + resultClass?: string; provider?: string; accountId?: string; resetAt?: number; + task?: string; parentSessionId?: string; status?: "failed"|"accepted"|"rejected"|"rework"; } interface Subscriber { @@ -79,8 +89,22 @@ interface Subscriber { const MAX_OWNED_PER_PLUGIN = 16; const MAX_SEND_CHARS = 16_000; const MAX_SCREEN_CHARS = 4_000; +const MAX_LOOP_EVIDENCE = 512; +const LOOP_EVIDENCE_TTL_MS = 60_000; const PROFILES = new Set(["normal", "yolo", "auto", "acceptEdits", "plan"]); +interface LoopEvidence { sessionId: string; turnEpoch: number; issuedAt: number } + +function boundedPluginText(value: string, limit: number): string { + const text = value.slice(0, limit); + return /[\uD800-\uDBFF]$/u.test(text) ? text.slice(0, -1) : text; +} + +function validTurnId(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 && value.length <= 160 + && /^[A-Za-z0-9._:-]+$/u.test(value) ? value : undefined; +} + /** * Session events and plugin-owned session control (EP-4). A service subscribes to card events (metadata only; * the redacted end of the output only with `sessions:read-screen`). It can start cards through the normal launch @@ -92,6 +116,8 @@ export class PluginSessions { private readonly deps: PluginSessionsDependencies; private readonly subscribers = new Map(); private readonly known = new Map(); + private readonly fingerprintKey = randomBytes(32); + private readonly loopEvidence = new Map(); constructor(deps: PluginSessionsDependencies) { this.deps = deps; @@ -134,6 +160,109 @@ export class PluginSessions { serviceStopped(pluginId: string, serviceId: string): void { this.subscribers.delete(`${pluginId}:${serviceId}`); } + activity(event: PluginActivity): void { + try { if(!isInstalledAssistant(this.deps.installRecord?.(ASSISTANT_PLUGIN_ID) ?? null))return; } + catch { return; } + let safeEvent = event.type === "tool-outcome" ? this.safeToolOutcome(event) + : event.type === "activity" || event.type === "pretool" ? this.safePretool(event) : event; + if (!safeEvent) return; + const context=this.deps.terminals.pluginContext(safeEvent.sessionId); + if (!context) return; + if ((safeEvent.type === "activity" || safeEvent.type === "tool-outcome") + && Number.isSafeInteger(safeEvent.turnEpoch) && safeEvent.turnEpoch! > 0 + && context.metadata.exitCode === null && context.metadata.status !== "done" && context.metadata.status !== "failed") { + const evidenceId = this.issueLoopEvidence(safeEvent.sessionId, safeEvent.turnEpoch!); + safeEvent = { ...safeEvent, evidenceId }; + } + for (const subscriber of this.subscribers.values()) { + if(subscriber.pluginId!==ASSISTANT_PLUGIN_ID || subscriber.serviceId!==ASSISTANT_SERVICE_ID)continue; + if (subscriber.ownedOnly && context.owner !== subscriber.pluginId) continue; + this.deps.notify(subscriber.pluginId,subscriber.serviceId,"canvastty.activity",safeEvent); + } + } + + /** Consume host evidence once, and only for the same still-live card and currently open turn. */ + consumeLoopEvidence(sessionId: string, evidenceId: string, currentTurnEpoch: number | null): boolean { + const evidence = this.loopEvidence.get(evidenceId); + if (!evidence) return false; + this.loopEvidence.delete(evidenceId); + const age = Date.now() - evidence.issuedAt; + if (evidence.sessionId !== sessionId || evidence.turnEpoch !== currentTurnEpoch + || !Number.isFinite(age) || age < 0 || age > LOOP_EVIDENCE_TTL_MS) return false; + const context = this.deps.terminals.pluginContext(sessionId); + return Boolean(context && context.metadata.exitCode === null + && context.metadata.status !== "done" && context.metadata.status !== "failed"); + } + + private issueLoopEvidence(sessionId: string, turnEpoch: number): string { + const now = Date.now(); + while (this.loopEvidence.size >= MAX_LOOP_EVIDENCE) this.loopEvidence.delete(this.loopEvidence.keys().next().value!); + const evidenceId = randomBytes(18).toString("base64url"); + this.loopEvidence.set(evidenceId, { sessionId, turnEpoch, issuedAt: now }); + return evidenceId; + } + + /** Equality survives inside one host session, while plugins cannot test guessed commands against plain SHA. */ + private fingerprint(sessionId:string,domain:string,hash:string):string { + return createHmac("sha256",this.fingerprintKey).update(JSON.stringify(["activity-v1",sessionId,domain,hash])).digest("hex"); + } + + private safeToolOutcome(event: PluginActivity): PluginActivity | null { + if (!Number.isFinite(event.at) || typeof event.sessionId !== "string" || !event.sessionId + || !["success", "error", "denied", "unknown"].includes(event.resultClass ?? "")) return null; + const changedPathHashes = Array.isArray(event.changedPathHashes) + ? [...new Set(event.changedPathHashes.filter((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)))].slice(0, 16) + : []; + const redactedName = typeof event.toolName === "string" + ? this.deps.terminals.redactSecrets(event.toolName.replace(/[\u0000-\u001f\u007f]/gu, "")) + : "unknown"; + const safeName = boundedPluginText(redactedName, 80); + const actionHash = typeof event.normalizedActionHash === "string" && /^[a-f0-9]{64}$/u.test(event.normalizedActionHash) + ? event.normalizedActionHash : undefined; + const errorHash = event.resultClass === "error" && typeof event.errorHash === "string" && /^[a-f0-9]{64}$/u.test(event.errorHash) + ? event.errorHash : undefined; + const outputHash = event.resultClass !== "error" && typeof event.outputHash === "string" && /^[a-f0-9]{64}$/u.test(event.outputHash) + ? event.outputHash : undefined; + const turnId = validTurnId(event.turnId); + return { + type: "tool-outcome", + sessionId: event.sessionId, + at: event.at, + ...(turnId ? { turnId } : {}), + ...(Number.isSafeInteger(event.turnEpoch) && (event.turnEpoch as number) > 0 ? { turnEpoch: event.turnEpoch as number } : {}), + toolName: safeName || "unknown", + resultClass: event.resultClass, + ...(actionHash ? { normalizedActionHash: this.fingerprint(event.sessionId,"action",actionHash) } : {}), + ...(errorHash ? { errorHash:this.fingerprint(event.sessionId,"error",errorHash) } : {}), + ...(outputHash ? { outputHash:this.fingerprint(event.sessionId,"output",outputHash) } : {}), + changedPathHashes:changedPathHashes.map(hash=>this.fingerprint(event.sessionId,"path",hash)) + }; + } + + /** Pre-tool summaries carry only hashes and bounded labels; tool input never reaches plugins. */ + private safePretool(event: PluginActivity): PluginActivity | null { + if (!Number.isFinite(event.at) || typeof event.sessionId !== "string" || !event.sessionId) return null; + const action = typeof event.normalizedAction === "string" ? event.normalizedAction : event.normalizedActionHash; + if (typeof action !== "string" || !/^[a-f0-9]{64}$/u.test(action)) return null; + const redactedName = typeof event.toolName === "string" + ? this.deps.terminals.redactSecrets(event.toolName.replace(/[\u0000-\u001f\u007f]/gu, "")) + : "unknown"; + const safeName = boundedPluginText(redactedName, 80); + const resultClass = ["deny", "ask", "allow"].includes(event.resultClass ?? "") ? event.resultClass : undefined; + const turnId = validTurnId(event.turnId); + return { + type: "activity", + sessionId: event.sessionId, + at: event.at, + toolName: safeName || "unknown", + normalizedAction: this.fingerprint(event.sessionId,"action",action), + ...(typeof event.normalizedActionHash==="string" && /^[a-f0-9]{64}$/u.test(event.normalizedActionHash) + ? {normalizedActionHash:this.fingerprint(event.sessionId,"action",event.normalizedActionHash)} : {}), + ...(Number.isSafeInteger(event.turnEpoch) && (event.turnEpoch as number) > 0 ? { turnEpoch: event.turnEpoch as number } : {}), + ...(resultClass ? { resultClass } : {}), + ...(turnId ? { turnId } : {}) + }; + } /** The EP-4 summary of one card, or null when it does not exist. */ summary(sessionId: string): PluginSessionSummary | null { @@ -162,6 +291,7 @@ export class PluginSessions { if (channel === IPC.terminalRemoved && "id" in payload && !("data" in payload)) { const last = this.known.get(payload.id); this.known.delete(payload.id); + for (const [evidenceId, evidence] of this.loopEvidence) if (evidence.sessionId === payload.id) this.loopEvidence.delete(evidenceId); if (last) this.dispatch("closed", last.summary, last.owner); return; } diff --git a/src/main/services/ProcessTreePause.ts b/src/main/services/ProcessTreePause.ts new file mode 100644 index 00000000..893cb527 --- /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 00000000..6b1aac33 --- /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 00000000..647e6ae9 --- /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/SessionReports.ts b/src/main/services/SessionReports.ts new file mode 100644 index 00000000..40afa621 --- /dev/null +++ b/src/main/services/SessionReports.ts @@ -0,0 +1,65 @@ +import {createHash,randomUUID} from "node:crypto"; +import {mkdir,rename,unlink,writeFile} from "node:fs/promises"; +import {join} from "node:path"; + +/** Completion snapshots use only the existing timeline. A later turn invalidates an older report. */ +export class SessionReports { + private readonly generations=new Map(); + private readonly ready=new Set(); + private readonly pending=new Map>(); + private readonly directory:string; + private readonly generate:(id:string)=>Promise; + private readonly changed:(id:string,readyAt:number|null)=>void; + private readonly now:()=>number; + private writes:Promise=Promise.resolve(); + constructor(userDataPath:string,generate:(id:string)=>Promise, + changed:(id:string,readyAt:number|null)=>void,now=Date.now){ + this.directory=join(userDataPath,"session-reports"); + this.generate=generate;this.changed=changed;this.now=now; + } + complete(id:string):Promise{ + const current=this.pending.get(id);if(current)return current; + const generation=(this.generations.get(id) ?? 0)+1;this.generations.set(id,generation); + const operation=this.save(id,generation).finally(()=>{if(this.pending.get(id)===operation)this.pending.delete(id);}); + this.pending.set(id,operation);return operation; + } + invalidate(id:string):void { + this.generations.set(id,(this.generations.get(id) ?? 0)+1);this.pending.delete(id); + if(this.ready.delete(id))this.changed(id,null); + } + forget(id:string):void{ + this.invalidate(id); + this.writes=this.writes.catch(()=>undefined).then(()=>unlink(this.path(id)).catch(()=>undefined)); + } + async report(id:string):Promise{ + await this.pending.get(id); + const generation=this.generations.get(id); + const report=await this.generate(id); + // Git audits and usage hooks can finish after the terminal reports completion. + if(this.ready.has(id) && generation!==undefined){ + const write=this.writes.catch(()=>undefined).then(()=>this.write(id,generation,report)); + this.writes=write;await write; + } + return report; + } + async flush():Promise{await Promise.allSettled(this.pending.values());await this.writes;} + private path(id:string):string{return join(this.directory,createHash("sha256").update(id).digest("hex")+".md");} + private async save(id:string,generation:number):Promise{ + const report=await this.generate(id); + if(this.generations.get(id)!==generation)return; + const write=this.writes.catch(()=>undefined).then(()=>this.write(id,generation,report)); + this.writes=write;await write; + } + private async write(id:string,generation:number,report:string):Promise{ + if(this.generations.get(id)!==generation)return; + await mkdir(this.directory,{recursive:true,mode:0o700}); + const file=this.path(id),temporary=`${file}.${randomUUID()}.tmp`; + try{ + await writeFile(temporary,report,{mode:0o600,flag:"wx"}); + if(this.generations.get(id)!==generation)return; + await rename(temporary,file); + if(this.generations.get(id)!==generation)return; + this.ready.add(id);this.changed(id,this.now()); + }finally{await unlink(temporary).catch(()=>undefined);} + } +} diff --git a/src/main/services/SessionTimelineIndexWorker.ts b/src/main/services/SessionTimelineIndexWorker.ts new file mode 100644 index 00000000..7453a25b --- /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 00000000..88a7d92d --- /dev/null +++ b/src/main/services/SessionTimelineService.ts @@ -0,0 +1,634 @@ +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, timelineUsageKey, + 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(sizerow.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 nextSampleOrder=0; + private readonly latestCounterSamples=new Map(); + 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; + this.nextSampleOrder=0; + this.latestCounterSamples.clear(); + 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 cost=costUsd ?? null,normalizedContext=timelineUsageContext(context); + const previous=this.usageBySession.get(timelineUsageKey(sessionId,{source,counterId,...normalizedContext})); + const latest=this.latestCounterSamples.get(snapshotIdentity(sessionId,{input,output,total,cost,source,counterId,...normalizedContext})); + if(previous?.cumulative && previous.sampleOrder===latest?.sampleOrder && 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 entries:Array<[string,Usage]>=scope ? [] : [...this.usageBySession]; + if (scope) { + for(const sessionId of scope)for(const key of this.usageKeysBySession.get(sessionId) ?? []) { + const value=this.usageBySession.get(key);if(value)entries.push([key,value]); + } + if(scope.size>1)entries.sort((left,right)=>(left[1].timelineOrder ?? 0)-(right[1].timelineOrder ?? 0)); + } + const values=new Map(); + for(const [key,value] of entries) { + // Cumulative snapshots follow a conversation across cards. Keep its latest observation, including a + // lower reset value, not the largest value or a resumed budget baseline. Additive rows remain per-card. + const identity=value.cumulative ? `counter:${snapshotIdentity(usageSession(key),value)}` : `additive:${key}`; + const previous=values.get(identity); + if(!previous || (value.sampleOrder ?? 0)>(previous.sampleOrder ?? 0))values.set(identity,value); + } + return usageSummary([...values.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=snapshotIdentity(event.sessionId,value); + // All-time rows are snapshots too: attribute a shared counter to its latest in-scope card once. + const rowKey=period==="all" && value.cumulative===true + ? JSON.stringify(["cumulative",counterKey]) : 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); + const latest=this.latestCounterSamples.get(snapshotIdentity(event.sessionId,update.value)); + this.storeUsage(update.key, event.sessionId, {...update.value,sampleOrder:this.nextSampleOrder++}); + if (usageCountersChanged(previous, update.value) || update.value.cumulative && previous?.sampleOrder!==latest?.sampleOrder) this.notifyUsageChanged(event.sessionId); + } + private storeUsage(key:string,sessionId:string,value:Usage):void { + const previous=this.usageBySession.get(key); + this.nextSampleOrder=Math.max(this.nextSampleOrder,(value.sampleOrder ?? -1)+1); + if(value.cumulative) { + const identity=snapshotIdentity(sessionId,value),latest=this.latestCounterSamples.get(identity); + if(!latest || (value.sampleOrder ?? 0)>(latest.sampleOrder ?? 0))this.latestCounterSamples.set(identity,value); + } + 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/SubagentWorktreeResolver.ts b/src/main/services/SubagentWorktreeResolver.ts new file mode 100644 index 00000000..12751baa --- /dev/null +++ b/src/main/services/SubagentWorktreeResolver.ts @@ -0,0 +1,15 @@ +import type { AgentControlOptions } from "./AgentControlService.ts"; +import type { EnvironmentProvider } from "./EnvironmentRegistry.ts"; + +/** Selection uses the trusted project root; worktree creation remains in the existing plugin. */ +export function subagentWorktreeResolver(dependencies: { + isGitProject(projectRoot: string): Promise; + providers(): EnvironmentProvider[]; +}): NonNullable { + return async request => { + if (!await dependencies.isGitProject(request.projectRoot)) return null; + const provider = dependencies.providers().find(row => row.kinds.some(kind => kind.kind === "worktree" + && (!kind.appliesTo || kind.appliesTo.includes(request.provider)))); + return provider ? { pluginId: provider.pluginId, kind: "worktree" } : null; + }; +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be296..6429889e 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 checkpointUnavailableGenerations=new Map(); + private checkpointRestore: {cwd:string;ids:Set;processes:Map;deferredResume:Set;closes:Map;done:Promise} | null=null; + 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 +322,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 +336,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 +344,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 +398,356 @@ 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); + } + + /** Restoration controls only this host's managed PTYs, not external editors or other OS processes. */ + isCheckpointRestoreActive(id:string):boolean {return this.checkpointRestore?.ids.has(id) ?? false;} + + canCaptureCheckpoint(id:string):boolean { + const session=this.sessions.get(id); + return Boolean(session && !this.isCheckpointRestoreActive(id) && this.checkpointUnavailableGenerations.get(id)!==(session.inputGeneration ?? 0)); + } + + private assertCheckpointLaunchAllowed(cwd:string,contributed=false):void { + const lease=this.checkpointRestore;if(!lease)return; + const folder=realpathSync(cwd); + if(contributed || isPathInside(lease.cwd,folder) || isPathInside(folder,lease.cwd)) { + throw new LaunchRefusal("Workspace checkpoint restoration is in progress. Try the launch again after it finishes."); + } + } + + /** Hold every overlapping owned process stopped across the complete Git restore transaction. */ + async withCheckpointRestore(id:string,restore:(cwd:string)=>Promise):Promise { + if(this.checkpointRestore)throw new Error("Another checkpoint restoration is already in progress."); + const target=this.sessions.get(id);if(!target)throw new Error("Terminal session does not exist."); + const cwd=realpathSync(this.launchContexts.get(id)?.cwd ?? target.metadata.cwd); + const targetScopes=new Set([cwd]);let targetScopeAmbiguous=false; + for(const path of [target.metadata.cwd,target.metadata.taskScope?.cwd ?? this.taskProjectRoot(target.metadata.cwd,target.metadata.parentSessionId)]) { + try {targetScopes.add(realpathSync(path));}catch {targetScopeAmbiguous=true;} + } + const affected:ManagedSession[]=[]; + for(const session of this.sessions.values()) { + // A pending plugin launch has not established its final cwd yet. Do not race its preparation/wrapping. + if(session.launchTasks?.size)throw new Error("Wait for pending agent launches before restoring files."); + const isolated=session.extras.isolatedEnvironmentScopes; + if(isolated && (isolated.ambiguous || (isolated.roots.length>0 && (targetScopeAmbiguous || isolated.roots.some(root=>[...targetScopes].some(target=>isPathInside(root,target) || isPathInside(target,root))))))) { + throw new Error("Checkpoint restoration is unavailable for a related isolated environment: pausing or exiting its local wrapper does not prove that the remote/container workload stopped. Stop and verify that workload independently; CanvasTTY has no environment suspension guarantee."); + } + let folder:string; + try {folder=realpathSync(this.launchContexts.get(session.metadata.id)?.cwd ?? session.metadata.cwd);} + catch(error) {if(session.metadata.exitCode!==null)continue;throw error;} + if(!isPathInside(cwd,folder) && !isPathInside(folder,cwd))continue; + if(session.metadata.exitCode===null) { + const progress=this.turnProgress(session.metadata.id); + if(!session.process || session.acceptedLifecycleState!=="idle" || (progress?.promptSent && !progress.turnStartedSincePrompt)) { + throw new Error("Stop the active turn and wait for observed agent idle before restoring files."); + } + if(!this.processTreePause.supported)throw new Error("This platform cannot safely suspend live agents for checkpoint restoration. Close them first."); + } + affected.push(session); + } + let complete!:()=>void; + const lease={cwd,ids:new Set(affected.map(session=>session.metadata.id)),processes:new Map(),deferredResume:new Set(),closes:new Map(),done:new Promise(resolve=>{complete=resolve;})}; + // Closing an ancestor closes its children and may release their environment; defer the whole close. + for(const session of affected) { + let parent=session.metadata.parentSessionId; + while(parent && !lease.ids.has(parent)){lease.ids.add(parent);parent=this.sessions.get(parent)?.metadata.parentSessionId;} + } + this.checkpointRestore=lease; + try { + for(const session of affected) { + // Existing pre-turn guards/cache entries must not describe the tree after a restoration. + session.inputGeneration=(session.inputGeneration ?? 0)+1; + // A delayed old hook must not label post-restore edits as a new before-turn snapshot. + this.checkpointUnavailableGenerations.set(session.metadata.id,session.inputGeneration); + const process=session.process;if(!process)continue; + lease.processes.set(process,{session,wasPaused:this.processTreePause.isPaused(process)}); + const paused=this.processTreePause.pause(process); + if(!paused.supported || paused.failed)throw new Error(`Could not safely suspend agents for restoration: ${paused.failed ?? "unsupported platform"}`); + } + return await restore(cwd); + } finally { + const failures:string[]=[]; + try { + for(const [process,{session,wasPaused}] of lease.processes) { + // An exited PTY no longer has another owner that can release its stopped descendants. + if(session.process===process && ((wasPaused && !lease.deferredResume.has(process)) || this.isSessionBudgetPaused(session)))continue; + const resumed=this.processTreePause.resume(process); + if(resumed.failed) { + // Never reopen input to a process that failed to resume. + this.budgetPausedTaskRoots.add(this.taskScopeFor(session.metadata.id).id); + failures.push(resumed.failed); + } + } + } finally { + this.checkpointRestore=null; + complete(); + for(const [closed,options] of lease.closes)this.dispose(closed,options); + for(const session of this.sessions.values()) { + const root=this.taskScopeFor(session.metadata.id).id; + if(!this.budgetPausedTaskRoots.has(root))this.launchDeferredBudgetSessions(root); + } + } + if(failures.length)throw new Error(`An agent remains budget-paused after checkpoint restoration cleanup because resuming failed: ${failures.join(" ")}`); + } + } + + /** 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; + if(!paused && this.checkpointRestore?.processes.has(session.process)){this.checkpointRestore.deferredResume.add(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) { + if(this.checkpointRestore)continue; + session.budgetDeferredLaunch=false; + this.launchAwaitingSession(session.metadata.id,session); + } + } + } + + setObservedUsage(id:string,usage:NonNullable,reviewUsage?:SessionMetadata["reviewUsage"]):void { + const session=this.sessions.get(id);if(!session)return; + if(JSON.stringify(session.metadata.usage)===JSON.stringify(usage) && JSON.stringify(session.metadata.reviewUsage)===JSON.stringify(reviewUsage))return; + session.metadata.usage=structuredClone(usage); + if(reviewUsage)session.metadata.reviewUsage=structuredClone(reviewUsage);else delete session.metadata.reviewUsage; + this.emitSession(session.metadata); + } + setSessionReport(id:string,readyAt:number|null):void { + const session=this.sessions.get(id);if(!session || session.metadata.sessionReport?.readyAt===readyAt)return; + if(readyAt===null){if(!session.metadata.sessionReport)return;delete session.metadata.sessionReport;} + else session.metadata.sessionReport={readyAt}; + this.emitSession(session.metadata); + } + + 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 +790,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); @@ -467,6 +884,7 @@ export class TerminalManager { } async shutdown(): Promise { + if(this.checkpointRestore)await this.checkpointRestore.done; if (this.persistenceTimer !== null) { clearTimeout(this.persistenceTimer); this.persistenceTimer = null; @@ -526,11 +944,45 @@ export class TerminalManager { return this.liveProcesses.size; } - private allProcessesExited(timeoutMs: number): Promise { + /** Stops only this card's owned PTY and waits for its existing exit watcher before it can reuse a workspace. */ + async stopSubagentPtyForRetry(id: string): Promise { + if(this.isCheckpointRestoreActive(id))throw new Error("Workspace checkpoint restoration is in progress."); + const session = this.sessions.get(id); + if (!session || session.metadata.role !== "subagent" || session.metadata.provider === "terminal") { + throw new Error("The retry source is no longer an available subagent."); + } + const process = session.process; + if (!process) { + if (session.metadata.exitCode !== null) return; + throw new Error("The retry source has no observable PTY to stop safely."); + } + const exited = this.liveProcesses.get(process); + if (!exited) throw new Error("The retry source PTY has no host exit watcher; its workspace was not reused."); + + const resumed = this.processTreePause.resume(process); + if (resumed.failed) throw new Error(`The retry source PTY could not be resumed before stopping: ${resumed.failed}`); + try { process.kill(); } catch { /* It may have exited as the quiet result was reported. */ } + if (await this.waitForOwnedProcess(exited, PTY_EXIT_WAIT_MS)) return; + + try { + // On Windows a PTY accepts no signal. On other hosts force only this exact owned PTY after the grace period. + if (globalThis.process.platform === "win32") process.kill(); + else process.kill("SIGKILL"); + } catch { /* The captured exit promise below decides whether it stopped. */ } + if (!await this.waitForOwnedProcess(exited, PTY_KILL_WAIT_MS)) { + throw new Error("The retry source PTY did not exit; its workspace was not reused."); + } + } + + private async waitForOwnedProcess(exited: Promise, timeoutMs: number): Promise { let timer: ReturnType | undefined; const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }); - const exited = Promise.all(this.liveProcesses.values()).then(() => true as const); - return Promise.race([exited, timedOut]).finally(() => clearTimeout(timer)); + try { return await Promise.race([exited.then(() => true as const), timedOut]); } + finally { clearTimeout(timer); } + } + + private allProcessesExited(timeoutMs: number): Promise { + return this.waitForOwnedProcess(Promise.all(this.liveProcesses.values()).then(() => undefined), timeoutMs); } /** @@ -585,11 +1037,12 @@ export class TerminalManager { return this.sessions.get(id)?.outputOffset ?? null; } - readBuffer(id: string): TerminalBufferSnapshot { + readBuffer(id: string, maxChars = MAX_SCROLLBACK_CHARS): TerminalBufferSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); + if (!Number.isSafeInteger(maxChars) || maxChars < 0) throw new Error("Invalid terminal buffer limit."); return { - buffer: session.bufferChunks.slice(session.bufferStart).join(""), + buffer: scrollbackTail(session, Math.min(maxChars, MAX_SCROLLBACK_CHARS)), outputOffset: session.outputOffset }; } @@ -605,9 +1058,12 @@ 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()); + this.assertCheckpointLaunchAllowed(request.cwd,Boolean(request.environment || request.launchOptions)); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; if (request.profile === "yolo" && request.provider !== "terminal") { // YOLO is the person's decision, made in the launcher for that CLI; nothing else starts it on their behalf. @@ -625,6 +1081,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 +1102,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 +1144,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 +1162,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 +1193,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 +1209,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 +1235,127 @@ 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) { + await this.stopSubagentPtyForRetry(session.metadata.id); + } 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."); + if(this.isCheckpointRestoreActive(session.metadata.id))throw new LaunchRefusal("Workspace checkpoint restoration is in progress."); + this.assertCheckpointLaunchAllowed(session.metadata.cwd,Boolean(session.extras.environment || session.extras.environmentChoice || session.extras.options)); 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); + this.checkpointUnavailableGenerations.delete(id); // Input queued for the launch that ended never reaches this one. session.launchEpoch += 1; this.wakeLaunchWaiters(session); @@ -876,15 +1467,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 +1533,85 @@ 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(this.isCheckpointRestoreActive(id))return false; + 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; } @@ -1005,14 +1682,50 @@ export class TerminalManager { return publicSessionMetadata(session); } + /** A host-only freshness token for a bounded pre-ack hook barrier; no renderer authority is granted. */ + providerSignalGuard(id: string, signal: ProviderLifecycleSignal): () => boolean { + const session = this.sessions.get(id), generation = session?.inputGeneration ?? 0; + return () => Boolean(session && this.sessions.get(id) === session && (session.inputGeneration ?? 0) === generation + && this.canApplyProviderSignal(id, signal)); + } + + /** Read-only host check before a lifecycle hook is acknowledged; applying the signal still happens once later. */ + canApplyProviderSignal(id: string, signal: ProviderLifecycleSignal): boolean { + if(this.isCheckpointRestoreActive(id))return false; + const session = this.sessions.get(id); + if (!session || session.metadata.status === "done" || session.metadata.status === "failed") return false; + if (session.metadata.provider !== "opencode" || !signal.requestId) return true; + const known = session.providerTurnGenerations?.get(signal.requestId); + return known === undefined + ? signal.event === "session.status:busy" || signal.event === "session.status:retry" + : known === (session.inputGeneration ?? 0) && session.providerTurnId === signal.requestId; + } + /** `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; + if (source === "hook" && !this.canApplyProviderSignal(id, signal)) 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 +1733,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,11 +1849,43 @@ 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. */ dispose(id: string, options: { keepEnvironmentData?: boolean } = {}): void { + if(this.isCheckpointRestoreActive(id)){this.checkpointRestore!.closes.set(id,options);return;} const session = this.sessions.get(id); if (!session) return; // A subagent belongs to its parent: closing the parent closes its subagents first (deepest first), so none keeps @@ -1131,7 +1898,14 @@ export class TerminalManager { this.flushOutput(id, session); this.sessions.delete(id); + this.checkpointUnavailableGenerations.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 +1920,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 +1938,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 +1977,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,9 +1985,11 @@ 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.isolatedEnvironmentScopes ? {isolatedEnvironmentScopes:structuredClone(descriptor.isolatedEnvironmentScopes)} : {}), ...(descriptor.options ? { options: descriptor.options } : {}), ...(descriptor.environment ? { environment: descriptor.environment } : {}), ...(descriptor.environmentChoice && !descriptor.environment ? { environmentChoice: descriptor.environmentChoice } : {}), @@ -1268,9 +2047,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 +2188,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 +2221,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 +2260,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 +2326,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 +2338,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 +2354,15 @@ 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.assertCheckpointLaunchAllowed(planned.cwd); + 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 +2375,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 +2412,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 +2437,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 +2450,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); @@ -1624,14 +2460,29 @@ export class TerminalManager { sessionId: id, provider, cwd: planned.cwd, + taskProjectRoot, command: planned.command, 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 +2517,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 +2563,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 +2584,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 +2705,12 @@ 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.assertCheckpointLaunchAllowed(metadata.cwd,true); + return 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 +2724,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,25 +2739,44 @@ 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}`); return "failed"; }; const environments = this.environments; + const requestedCwd=metadata.cwd; + + 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; if (choice && !session.extras.environment) { if (!environments) return refuse("plugin environments are not available."); - const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice }); + const placed = await environments.prepare({ sessionId: id, provider: metadata.provider, cwd: metadata.cwd, choice, + projectRoot:metadata.taskScope?.cwd ?? this.taskProjectRoot(metadata.cwd,metadata.parentSessionId) }); if (!live()) { // An answer for a launch that no longer exists (the card was closed or restarted, or the app is quitting) // is never adopted or saved: nobody used it, so it is released at once and nothing is kept. 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 +2799,179 @@ 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 ? environments?.keeps?.(environment) ?? {} : {}; + const isolatedEnvironment=Boolean(environment && keeps.isolated===true); + 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.assertCheckpointLaunchAllowed(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(environment)session.extras.isolatedEnvironmentScopes ??= {roots:[],ambiguous:false}; + if(isolatedEnvironment) { + const evidence=session.extras.isolatedEnvironmentScopes!; + // Keep the declaration used for this launch, never re-read a changed/removed plugin at restore time. + for(const path of [requestedCwd,taskProjectRoot,metadata.cwd,spawn.cwd]) { + try { + const root=realpathSync(path); + if(!evidence.roots.includes(root)) { + if(evidence.roots.length<32)evidence.roots.push(root);else evidence.ambiguous=true; + } + } catch {evidence.ambiguous=true;} + } + } + 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 +2995,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 +3018,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.checkpointRestore?.processes.has(process) ? {supported:true} : 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 +3161,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 +3336,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 +3398,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/TerminalOutputHistory.ts b/src/main/services/TerminalOutputHistory.ts new file mode 100644 index 00000000..d687326f --- /dev/null +++ b/src/main/services/TerminalOutputHistory.ts @@ -0,0 +1,393 @@ +import { existsSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { randomBytes } from "node:crypto"; +import { fork, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import type { TerminalOutputContext, TerminalOutputSearchResult } from "../../shared/contracts.ts"; + +export interface TerminalOutputRedactionSnapshot { + revision: number; + values: string[]; +} + +export interface TerminalHistoryRecoverySnapshot { + sessionId: string; + buffer: string; + outputOffset: number; +} + +interface WorkerReply { requestId: number; ok: boolean; value?: unknown; error?: string } +interface PendingRequest { resolve(value: unknown): void; reject(error: Error): void } +interface OutboundBatch { worker: ChildProcess; requestId: number; items: object[]; dataChars: number; promise: Promise } +export interface TerminalOutputHistoryOptions { + /** + * A private directory for the worker's crash-recovery spill: output encrypted with a key held only in this + * process's memory, bounded like the in-memory history, wiped on a fresh start and on close. + */ + spillDirectory?: string; +} + +function historyWorkerEnvironment(): NodeJS.ProcessEnv { + return { + ELECTRON_RUN_AS_NODE: "1", + ...(process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}) + }; +} + +const MAX_QUEUED_CHARS = 8_000_000; +const MAX_IPC_BATCH_REQUESTS = 64; +const MAX_IPC_BATCH_DATA_CHARS = 256_000; +// A 50M UTF-16 history plus masked search caches kept the output worker's RSS +// hundreds of megabytes above startup. Keep the worker's raw and recovery +// budgets aligned so a restart cannot briefly recreate the old peak. +const MAX_RECOVERY_CHARS = 16_000_000; +const MAX_RECOVERY_SESSIONS = 1_024; + +/** Read newest tails first without copying data the worker would immediately prune. */ +export function collectTerminalHistoryRecoverySnapshots( + sessionIds: string[], + readBuffer: (sessionId: string, maxChars: number) => { buffer: string; outputOffset: number }, + maxChars = MAX_RECOVERY_CHARS +): TerminalHistoryRecoverySnapshot[] { + if (!Number.isSafeInteger(maxChars) || maxChars < 0 || maxChars > MAX_RECOVERY_CHARS) throw new Error("Invalid terminal history recovery limit."); + if (sessionIds.length > MAX_RECOVERY_SESSIONS || sessionIds.some(id => !validId(id))) throw new Error("Unsupported terminal history recovery session count or IDs."); + let remaining = maxChars; + const snapshots: TerminalHistoryRecoverySnapshot[] = []; + for (const sessionId of [...sessionIds].reverse()) { + try { + const snapshot = readBuffer(sessionId, remaining); + const buffer = remaining > 0 ? snapshot.buffer.slice(-remaining) : ""; + remaining -= buffer.length; + // Keep offsets even when no tail fits: omitted live sessions must report a history gap. + snapshots.push({ sessionId, buffer, outputOffset: snapshot.outputOffset }); + } catch { /* A card may have been removed immediately before recovery. */ } + } + return snapshots.reverse(); +} + +/** + * Bounded output history for search and context reads. Raw text reaches disk only encrypted, under a key that never + * leaves this process's memory (the optional crash-recovery spill); only the trusted worker can materialize a + * complete masked copy, using the host registry snapshot for the current revision. + */ +export class TerminalOutputHistory { + private worker: ChildProcess | null = null; + private emptyWorkerTimer: NodeJS.Timeout | null = null; + private readonly pending = new Map(); + private outbound: OutboundBatch | null = null; + private outboundFlush: NodeJS.Immediate | null = null; + private readonly spillDirectory: string | null; + private readonly spillKey = randomBytes(32); + private readonly gapPending = new Set(); + private readonly activeSessionIds = new Set(); + private nextRequestId = 1; + private queuedChars = 0; + private closed = false; + private needsRecovery = false; + private generation = 0; + private secretsRevision = -1; + private readonly getRedactionSnapshot: () => TerminalOutputRedactionSnapshot; + private readonly getRecoverySnapshots: () => TerminalHistoryRecoverySnapshot[]; + + constructor(getRedactionSnapshot: () => TerminalOutputRedactionSnapshot, + getRecoverySnapshots: () => TerminalHistoryRecoverySnapshot[] = () => [], options: TerminalOutputHistoryOptions = {}) { + this.getRedactionSnapshot = getRedactionSnapshot; + this.getRecoverySnapshots = getRecoverySnapshots; + this.spillDirectory = typeof options.spillDirectory === "string" && options.spillDirectory ? options.spillDirectory : null; + } + + /** Append a raw PTY batch in FIFO order. Duplicate startup snapshots are discarded by absolute source offset. */ + append(sessionId: string, data: string, outputOffset: number): Promise { + this.assertOpen(); + if (!validId(sessionId) || typeof data !== "string" || data.length > 1_000_000 || !Number.isSafeInteger(outputOffset) || outputOffset < 0) { + return Promise.reject(new Error("Invalid terminal output history chunk.")); + } + if (this.gapPending.has(sessionId)) return Promise.resolve(); + if (this.queuedChars + data.length > MAX_QUEUED_CHARS) { + this.gapPending.add(sessionId); + const work = this.request("gap", { sessionId, outputOffset }); + const generation = this.generation; + return work.then(() => undefined).finally(() => { if (generation === this.generation) this.gapPending.delete(sessionId); }); + } + this.queuedChars += data.length; + const work = this.request("append", { sessionId, data, outputOffset }); + const generation = this.generation; + return work + .then(() => undefined) + .finally(() => { if (generation === this.generation) this.queuedChars = Math.max(0, this.queuedChars - data.length); }); + } + + remove(sessionId: string): Promise { + if (!validId(sessionId)) return Promise.resolve(); + this.gapPending.delete(sessionId); + return this.request("remove", { sessionId }).then(() => undefined); + } + + async search(query: string, sessionIds?: string[]): Promise { + this.assertOpen(); + if (query.length > 200 || !query.trim()) return { matches: [], prunedSessionIds: [] }; + if (sessionIds && (sessionIds.length > 256 || sessionIds.some((id) => !validId(id)))) throw new Error("Invalid terminal output search scope."); + await this.syncRedactionSnapshot(); + return await this.request("search", { query, sessionIds: sessionIds ?? null }) as TerminalOutputSearchResult; + } + + async readContext(sessionId: string, offset: number): Promise { + this.assertOpen(); + if (!validId(sessionId) || !Number.isSafeInteger(offset) || offset < 0) throw new Error("Invalid terminal output context request."); + await this.syncRedactionSnapshot(); + return await this.request("context", { sessionId, offset }) as TerminalOutputContext; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.clearEmptyWorkerTimer(); + this.clearOutboundQueue(); + const worker = this.worker; + if (!worker) { + if (this.spillDirectory) await rm(this.spillDirectory, { recursive: true, force: true }).catch(() => undefined); + return; + } + const error = new Error("Terminal output history is shutting down."); + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + await stopWorker(worker); + this.worker = null; + if (this.spillDirectory) await rm(this.spillDirectory, { recursive: true, force: true }).catch(() => undefined); + } + + private async syncRedactionSnapshot(): Promise { + this.ensureWorker(); + const worker = this.worker; + const snapshot = this.redactionSnapshot(); + if (snapshot.revision === this.secretsRevision) return; + await this.request("secrets", { revision: snapshot.revision, values: snapshot.values }); + if (this.worker === worker) this.secretsRevision = snapshot.revision; + } + + private redactionSnapshot(): TerminalOutputRedactionSnapshot { + const snapshot = this.getRedactionSnapshot(); + if (!snapshot || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || !Array.isArray(snapshot.values) + || snapshot.values.length > 32_768 || snapshot.values.some((value) => typeof value !== "string" || value.length > 65_536)) { + throw new Error("Terminal output redaction is unavailable."); + } + return snapshot; + } + + private request(type: string, data: Record): Promise { + if (this.closed && type !== "close") return Promise.reject(new Error("Terminal output history is closed.")); + let worker: ChildProcess; + try { + this.ensureWorker(); + if (!this.worker) throw new Error("Terminal output history worker could not start."); + worker = this.worker; + this.clearEmptyWorkerTimer(); + if ((type === "append" || type === "gap") && typeof data.sessionId === "string") this.activeSessionIds.add(data.sessionId); + else if (type === "remove" && typeof data.sessionId === "string") this.activeSessionIds.delete(data.sessionId); + } + catch (error) { return Promise.reject(error instanceof Error ? error : new Error("Terminal output history worker could not start.")); } + if (type === "append" || type === "gap" || type === "remove") return this.enqueue(worker, { type, ...data }); + // Queries and snapshots go after every queued write, as one message each. + this.flushOutbound(worker); + const requestId = this.nextRequestId++; + return new Promise((resolve, reject) => { + this.pending.set(requestId, { resolve, reject }); + this.sendNow(worker, { requestId, type, ...data }); + }); + } + + /** + * Writes travel in batches that share one acknowledgement: a busy terminal produces many small PTY chunks, and a + * reply, a pending entry and a promise per chunk cost more than the chunk itself. + */ + private enqueue(worker: ChildProcess, item: Record): Promise { + const dataChars = typeof item.data === "string" ? item.data.length : 0; + if (this.outbound && (this.outbound.worker !== worker || this.outbound.dataChars + dataChars > MAX_IPC_BATCH_DATA_CHARS)) this.flushOutbound(this.outbound.worker); + if (!this.outbound) { + const requestId = this.nextRequestId++; + const promise = new Promise((resolve, reject) => this.pending.set(requestId, { resolve, reject })); + this.outbound = { worker, requestId, items: [], dataChars: 0, promise }; + this.scheduleOutboundFlush(); + } + const batch = this.outbound; + batch.items.push(item); + batch.dataChars += dataChars; + if (batch.items.length >= MAX_IPC_BATCH_REQUESTS || batch.dataChars >= MAX_IPC_BATCH_DATA_CHARS) this.flushOutbound(worker); + return batch.promise; + } + + private ensureWorker(): void { + if (this.worker) return; + this.assertOpen(); + const recovering = this.needsRecovery; + const snapshots = recovering ? this.getRecoverySnapshots() : []; + if (!Array.isArray(snapshots) || snapshots.length > MAX_RECOVERY_SESSIONS || snapshots.some(snapshot => + !validId(snapshot.sessionId) || typeof snapshot.buffer !== "string" || snapshot.buffer.length > 1_000_000 + || !Number.isSafeInteger(snapshot.outputOffset) || snapshot.outputOffset < snapshot.buffer.length)) { + throw new Error("Terminal history recovery snapshots are invalid."); + } + const path = workerEntryPath(); + const redaction = this.redactionSnapshot(); + const worker = fork(path, [], { + // Direct Node tests can fork the source .ts worker on Node versions that require this flag. + // Electron production loads the built .js worker instead. + execArgv: [ + ...(path.endsWith(".ts") ? ["--experimental-strip-types"] : []), + // Raw history lives outside the V8 heap; keep the heap itself compact instead of fast-growing. + "--optimize-for-size", + "--max-old-space-size=128", + "--max-semi-space-size=2" + ], + env: historyWorkerEnvironment(), + stdio: ["ignore", "ignore", "ignore", "ipc"], + serialization: "advanced" + }); + this.worker = worker; + if (recovering) { + this.activeSessionIds.clear(); + for (const snapshot of snapshots) this.activeSessionIds.add(snapshot.sessionId); + } + worker.on("message", (reply: WorkerReply | WorkerReply[]) => { + if (this.worker !== worker) return; + for (const item of Array.isArray(reply) ? reply : [reply]) this.onReply(item); + }); + worker.on("error", (error) => this.fail(worker, error instanceof Error ? error : new Error(String(error)))); + worker.once("exit", (code) => { + if (!this.closed) this.fail(worker, new Error(`Terminal output history worker exited (${code ?? 1}).`)); + }); + this.needsRecovery = false; + this.secretsRevision = redaction.revision; + // A fresh start wipes an earlier run's spill; a restart after a crash reads it back first. + if (this.spillDirectory) this.sendNow(worker, { requestId: 0, type: "spill", directory: this.spillDirectory, key: this.spillKey, recover: recovering }); + this.sendNow(worker, { requestId: 0, type: "secrets", revision: redaction.revision, values: redaction.values }); + // FIFO messages seed recent PTY tails before queries; positive source offsets mark lost history. + let remaining = MAX_RECOVERY_CHARS; + const bounded = snapshots.map(snapshot => ({ ...snapshot })); + for (let index = bounded.length - 1; index >= 0; index--) { + const snapshot = bounded[index]!; + snapshot.buffer = remaining > 0 ? snapshot.buffer.slice(-remaining) : ""; + remaining -= snapshot.buffer.length; + } + for (const snapshot of bounded) this.sendNow(worker, { requestId: 0, type: "append", sessionId: snapshot.sessionId, + data: snapshot.buffer, outputOffset: snapshot.outputOffset }); + } + + private onReply(reply: WorkerReply): void { + if (!reply || !Number.isSafeInteger(reply.requestId)) return; + const entry = this.pending.get(reply.requestId); + if (!entry) return; + this.pending.delete(reply.requestId); + if (!reply.ok) entry.reject(new Error(typeof reply.error === "string" ? reply.error : "Terminal output history request failed.")); + else entry.resolve(reply.value); + this.scheduleEmptyWorkerTermination(); + } + + private scheduleOutboundFlush(): void { + if (this.outboundFlush) return; + const scheduled = setImmediate(() => { + if (this.outboundFlush !== scheduled) return; + this.outboundFlush = null; + if (this.outbound) this.flushOutbound(this.outbound.worker); + }); + this.outboundFlush = scheduled; + } + + private flushOutbound(worker: ChildProcess): void { + const batch = this.outbound; + if (!batch || batch.worker !== worker) return; + if (this.outboundFlush) clearImmediate(this.outboundFlush); + this.outboundFlush = null; + this.outbound = null; + if (this.worker !== worker) return; + this.sendNow(worker, { requestId: batch.requestId, type: "batch", items: batch.items }); + } + + private sendNow(worker: ChildProcess, message: object): void { + if (this.worker !== worker) return; + if (!worker.connected) { + this.fail(worker, new Error("Terminal output history process is disconnected.")); + return; + } + try { + worker.send(message, error => { if (error) this.fail(worker, error); }); + } catch (reason) { + this.fail(worker, reason instanceof Error ? reason : new Error("Terminal output history worker rejected a request.")); + } + } + + private clearOutboundQueue(): void { + if (this.outboundFlush) clearImmediate(this.outboundFlush); + this.outboundFlush = null; + this.outbound = null; + } + + private fail(worker: ChildProcess, error: Error): void { + if (this.worker !== worker) return; + this.clearEmptyWorkerTimer(); + this.clearOutboundQueue(); + this.worker = null; + this.needsRecovery = true; + this.generation++; + this.secretsRevision = -1; + for (const entry of this.pending.values()) entry.reject(error); + this.pending.clear(); + this.queuedChars = 0; + this.gapPending.clear(); + void stopWorker(worker); + } + + private scheduleEmptyWorkerTermination(): void { + this.clearEmptyWorkerTimer(); + if (this.activeSessionIds.size > 0 || this.pending.size > 0 || !this.worker || this.closed) return; + const worker = this.worker; + this.emptyWorkerTimer = setTimeout(() => { + this.emptyWorkerTimer = null; + if (this.worker !== worker || this.activeSessionIds.size > 0 || this.pending.size > 0 || this.closed) return; + // Keep full history searchable while any session remains. Once every session has been removed and + // the worker is quiescent, its raw and masked heaps have no remaining user-visible value. + this.worker = null; + this.needsRecovery = true; + this.secretsRevision = -1; + void stopWorker(worker); + }, 0); + this.emptyWorkerTimer.unref(); + } + + private clearEmptyWorkerTimer(): void { + if (!this.emptyWorkerTimer) return; + clearTimeout(this.emptyWorkerTimer); + this.emptyWorkerTimer = null; + } + + private assertOpen(): void { + if (this.closed) throw new Error("Terminal output history is closed."); + } +} + +function validId(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 256; } + +function stopWorker(worker: ChildProcess): Promise { + if (worker.exitCode !== null || worker.signalCode !== null) return Promise.resolve(); + return new Promise(resolve => { + const onExit = (): void => resolve(); + worker.once("exit", onExit); + try { + if (!worker.kill()) { + worker.removeListener("exit", onExit); + resolve(); + } + } catch { + worker.removeListener("exit", onExit); + resolve(); + } + }); +} + +function workerEntryPath(): string { + // electron-vite emits the dedicated worker beside out/main/index.js; direct Node tests run the source .ts file. + const built = fileURLToPath(new URL("./TerminalOutputHistoryWorker.js", import.meta.url)); + if (existsSync(built)) return built; + const source = fileURLToPath(new URL("./TerminalOutputHistoryWorker.ts", import.meta.url)); + if (existsSync(source)) return source; + throw new Error("Terminal output history worker entry is missing."); +} diff --git a/src/main/services/TerminalOutputHistoryWorker.ts b/src/main/services/TerminalOutputHistoryWorker.ts new file mode 100644 index 00000000..74a55165 --- /dev/null +++ b/src/main/services/TerminalOutputHistoryWorker.ts @@ -0,0 +1,754 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; +import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { redactionRegistryFromWorkerSnapshot } from "./safety/SecretRedaction.ts"; + +interface WorkerReply { requestId: number; ok: boolean; value?: unknown; error?: string } + +/** + * Raw output is kept outside the V8 heap: one-byte text as latin1 bytes, other text as UTF-16. The worker runs with a + * small heap limit, and 16M retained characters as heap strings left most of that limit (and its garbage-collection + * slack) to the history alone. + */ +type Chunk = { data: Buffer; wide: boolean; length: number; start: number; owner: History; sessionNext: Chunk | null; globalPrevious: Chunk | null; globalNext: Chunk | null }; +type AnsiState = "text" | "escape" | "csi" | "osc" | "osc-escape" | "string" | "string-escape"; +/** A masked piece of history, ending at a boundary SecretRedaction proved no match crosses. */ +type MaskedSegment = { text: string; sourceEnd: number; newlines: number }; +/** + * The masked copy that search and context read. It is built when a query arrives (never per PTY append): segments + * up to `frontier` stay valid while output grows, so a query masks only what arrived since the previous one. A new + * secret set starts a new copy. Pruned raw output drops whole segments from the head. + */ +type MaskedStore = { + revision: number; + segments: MaskedSegment[]; + head: number; + frontier: number; + /** Absolute offset and line number of the first retained segment (what context reports as its base). */ + viewStart: number; + lineStart: number; + /** The masked text after `frontier`, for the source up to `upTo`. */ + tail: { upTo: number; text: string; newlines: number } | null; + /** Characters held in segments from `head` on. */ + chars: number; +}; +type History = { + head: Chunk | null; + tail: Chunk | null; + rawEnd: number; + nextOffset: number; + baseOffset: number; + baseLine: number; + pruned: boolean; + /** Constant-space ECMA-48 decoder state; control-string contents are never retained. */ + ansiState: AnsiState; + masked: MaskedStore | null; + spill: SpillState | null; +}; +type SpillState = { directory: string; file: string | null; fileChars: number; queue: Buffer[]; queuedChars: number }; + +const MAX_GLOBAL_CHARS = 16_000_000; +const MAX_CHUNK_CHARS = 16_384; +/** Source characters masked per step; far beyond any held value's reach (see windowChars). */ +const MASK_WINDOW_CHARS = 1_048_576; +/** Segments shorter than this absorb the next one, so frequent queries do not leave thousands of tiny parts. */ +const MIN_SEGMENT_CHARS = 65_536; +/** + * Masked copies of every session together: a segment straddling a session's pruned start is kept whole, so the copies + * can exceed the raw history slightly; past this, copies of sessions not being queried are dropped and rebuilt later. + */ +const MAX_MASKED_CHARS = 24_000_000; +const ASCII_NEEDLE = /^[\u0000-\u007f]*$/u; +/** Non-ASCII characters whose lower case holds ASCII letters (dotted I, Kelvin): a line holding one is folded. */ +const ASCII_FOLDING_CHARS = /[\u0130\u212a]/u; +const ASCII_FOLD_CHARS = /[A-Z\u0130\u212a]/u; +const WIDE_CHARS = /[^\u0000-\u00ff]/u; +const GAP_MARKER = "[CanvasTTY: terminal output is missing here]\n"; +/** Encrypted crash-recovery spill: flushed at most this often, or when this much is queued. */ +const SPILL_FLUSH_MS = 1_000; +const SPILL_FLUSH_CHARS = 262_144; +const SPILL_FILE_CHARS = 2_097_152; +const histories = new Map(); +let globalHead: Chunk | null = null; +let globalTail: Chunk | null = null; +let globalLength = 0; +let secretRevision = -1; +let redactor = redactionRegistryFromWorkerSnapshot([]); +let spill: { root: string; key: Buffer; files: Array<{ path: string; chars: number; owner: string }>; chars: number; sequence: number; timer: NodeJS.Timeout | null } | null = null; + +if (typeof process.send !== "function") throw new Error("Terminal output history worker requires an IPC parent."); + +process.on("message", (message: any) => { + if (message?.type === "batch" && Array.isArray(message.items)) { + // Output, gaps and removals arrive in batches with one acknowledgement each. + let error: string | undefined; + for (const item of message.items) { + const result = handleRequest(item); + if (!result.reply.ok && error === undefined) error = result.reply.error; + } + process.send?.(error === undefined ? { requestId: message.requestId, ok: true } : { requestId: message.requestId, ok: false, error }); + return; + } + const result = handleRequest(message); + process.send?.(result.reply); + if (result.disconnect) process.disconnect?.(); +}); + +function handleRequest(message: any): { reply: WorkerReply; disconnect: boolean } { + const id = typeof message?.requestId === "number" ? message.requestId : 0; + try { + let value: unknown; + let disconnect = false; + switch (message?.type) { + case "spill": + configureSpill(message.directory, message.key, message.recover === true); + value = true; + break; + case "secrets": + if (Number.isSafeInteger(message.revision) && message.revision >= 0 && Array.isArray(message.values) + && message.values.length <= 32_768 && message.values.every((entry: unknown) => typeof entry === "string" && entry.length <= 65_536)) { + if (message.revision !== secretRevision) { + secretRevision = message.revision; + redactor = redactionRegistryFromWorkerSnapshot(message.values); + for (const history of histories.values()) history.masked = null; + } + value = { revision: secretRevision }; + } else throw new Error("Invalid terminal-history redaction snapshot."); + break; + case "append": + append(message.sessionId, message.data, message.outputOffset); + value = true; + break; + case "gap": + markGap(message.sessionId, message.outputOffset); + value = true; + break; + case "remove": + remove(message.sessionId); + value = true; + break; + case "search": + value = search(message.query, message.sessionIds); + break; + case "context": + value = context(message.sessionId, message.offset); + break; + case "close": + disconnect = true; + value = true; + break; + default: + throw new Error("Unknown terminal-history operation."); + } + return { reply: { requestId: id, ok: true, value }, disconnect }; + } catch (error) { + return { reply: { requestId: id, ok: false, error: error instanceof Error ? error.message : "Terminal history operation failed." }, disconnect: false }; + } +} + +function historyFor(sessionId: string): History { + let history = histories.get(sessionId); + if (!history) { + history = { head: null, tail: null, rawEnd: 0, nextOffset: 0, baseOffset: 0, baseLine: 0, pruned: false, ansiState: "text", masked: null, spill: null }; + histories.set(sessionId, history); + } + return history; +} + +function append(sessionId: unknown, data: unknown, outputOffset: unknown): void { + if (typeof sessionId !== "string" || sessionId.length < 1 || sessionId.length > 256 + || typeof data !== "string" || data.length > 1_000_000 || !Number.isSafeInteger(outputOffset) || (outputOffset as number) < 0) { + throw new Error("Invalid terminal output history chunk."); + } + const history = historyFor(sessionId); + const end = outputOffset as number; + const start = end - data.length; + if (end <= history.rawEnd) return; // A startup seed and the live PTY stream can cover the same output. + let suffix = data; + if (start < history.rawEnd) suffix = data.slice(history.rawEnd - start); + if (start > history.rawEnd) openGap(sessionId, history); + const clean = stripAnsi(suffix, history.ansiState); + history.ansiState = clean.state; + history.rawEnd = end; + if (clean.text) appendText(sessionId, history, clean.text); +} + +function markGap(sessionId: unknown, outputOffset: unknown): void { + if (typeof sessionId !== "string" || !Number.isSafeInteger(outputOffset) || (outputOffset as number) < 0) throw new Error("Invalid terminal history gap."); + const history = historyFor(sessionId); + if ((outputOffset as number) > history.rawEnd) { + openGap(sessionId, history); + history.rawEnd = outputOffset as number; + } +} + +/** Lost output: the history says so where it happened, and search reports the session as incomplete. */ +function openGap(sessionId: string, history: History): void { + history.ansiState = "text"; + history.pruned = true; + if (history.nextOffset > 0) appendText(sessionId, history, history.tail && lastChar(history.tail) !== "\n" ? `\n${GAP_MARKER}` : GAP_MARKER); +} + +function lastChar(chunk: Chunk): string { + return chunk.wide ? chunk.data.toString("utf16le", chunk.data.length - 2) : chunk.data.toString("latin1", chunk.data.length - 1); +} + +function appendText(sessionId: string, history: History, text: string): void { + for (let cursor = 0; cursor < text.length;) { + const part = text.length <= MAX_CHUNK_CHARS && cursor === 0 ? text : text.slice(cursor, cursor + MAX_CHUNK_CHARS); + const wide = WIDE_CHARS.test(part); + const chunk: Chunk = { data: Buffer.from(part, wide ? "utf16le" : "latin1"), wide, length: part.length, start: history.nextOffset, + owner: history, sessionNext: null, globalPrevious: globalTail, globalNext: null }; + if (history.tail) history.tail.sessionNext = chunk; + else history.head = chunk; + history.tail = chunk; + if (globalTail) globalTail.globalNext = chunk; + else globalHead = chunk; + globalTail = chunk; + history.nextOffset += part.length; + globalLength += part.length; + cursor += part.length; + queueSpill(sessionId, history, chunk); + } + // A whole append is queued by now, so a record never claims PTY output it does not hold. + if (history.spill && history.spill.queuedChars >= SPILL_FLUSH_CHARS) flushHistorySpill(history.spill); + while (globalLength > MAX_GLOBAL_CHARS && globalHead) pruneChunk(globalHead); +} + +function pruneChunk(chunk: Chunk): void { + const owner = chunk.owner; + owner.baseOffset = chunk.start + chunk.length; + owner.baseLine += countNewlines(decode(chunk)); + owner.pruned = true; + globalLength -= chunk.length; + // Global eviction always removes the oldest remaining block of its session. + owner.head = chunk.sessionNext; + if (!owner.head) owner.tail = null; + globalHead = chunk.globalNext; + if (globalHead) globalHead.globalPrevious = null; + else globalTail = null; + chunk.sessionNext = null; + chunk.globalNext = null; +} + +function remove(sessionId: unknown): void { + if (typeof sessionId !== "string") return; + const history = histories.get(sessionId); + if (!history) return; + for (let chunk = history.head; chunk;) { + const next = chunk.sessionNext; + globalLength -= chunk.length; + if (chunk.globalPrevious) chunk.globalPrevious.globalNext = chunk.globalNext; + else globalHead = chunk.globalNext; + if (chunk.globalNext) chunk.globalNext.globalPrevious = chunk.globalPrevious; + else globalTail = chunk.globalPrevious; + chunk.globalPrevious = null; + chunk.globalNext = null; + chunk.sessionNext = null; + chunk = next; + } + histories.delete(sessionId); + dropSpill(sessionId); +} + +function decode(chunk: Chunk): string { return chunk.data.toString(chunk.wide ? "utf16le" : "latin1"); } + +/** The retained source text in [from, to), joined from at most one window's chunks. */ +function collectText(history: History, from: number, to: number): string { + const parts: string[] = []; + for (let chunk = history.head; chunk && chunk.start < to; chunk = chunk.sessionNext) { + const chunkEnd = chunk.start + chunk.length; + if (chunkEnd <= from) continue; + const text = decode(chunk); + parts.push(from > chunk.start || to < chunkEnd ? text.slice(Math.max(0, from - chunk.start), Math.min(chunk.length, to - chunk.start)) : text); + } + return parts.length === 1 ? parts[0]! : parts.join(""); +} + +/** Bring a history's masked copy up to its newest output; returns the parts search and context read. */ +function maskedView(history: History): { parts: string[]; start: number; line: number; store: MaskedStore } { + let store = history.masked; + if (!store || store.revision !== secretRevision) store = history.masked = freshStore(history); + while (store.head < store.segments.length && store.segments[store.head]!.sourceEnd <= history.baseOffset) { + const dropped = store.segments[store.head++]!; + store.chars -= dropped.text.length; + store.viewStart += dropped.text.length; + store.lineStart += dropped.newlines; + } + if (store.head > 64 && store.head * 2 > store.segments.length) { + store.segments = store.segments.slice(store.head); + store.head = 0; + } + if (store.frontier < history.baseOffset) { + // Output newer than the last query was pruned before it was masked: continue from what is retained. + const restart = freshStore(history); + store.viewStart += Math.max(0, restart.viewStart - store.frontier); + store.frontier = restart.frontier; + store.lineStart = Math.max(store.lineStart, restart.lineStart); + store.tail = null; + } + const window = windowChars(); + while (store.frontier < history.nextOffset) { + const end = Math.min(history.nextOffset, store.frontier + window); + const text = collectText(history, store.frontier, end); + let cut = 0; + let masked = ""; + const stable = redactor.redactStablePrefix(text); + if (stable.sourceLength > 0) { + cut = stable.sourceLength; + masked = stable.maskedText; + } else if (text.length >= window) { + // A whole window without a line start that is safe to cut at (one line of megabytes). + const forced = redactor.forcedBoundary(text, text.length - 1); + if (forced !== null) { + cut = forced; + masked = redactor.redact(text.slice(0, forced)); + } + } + if (cut <= 0) break; + const previous = store.segments.length > store.head ? store.segments[store.segments.length - 1]! : null; + if (previous && previous.text.length < MIN_SEGMENT_CHARS) { + previous.text += masked; + previous.sourceEnd = store.frontier + cut; + previous.newlines += countNewlines(masked); + } else store.segments.push({ text: masked, sourceEnd: store.frontier + cut, newlines: countNewlines(masked) }); + store.chars += masked.length; + store.frontier += cut; + store.tail = null; + } + if (store.frontier < history.nextOffset) { + if (store.tail?.upTo !== history.nextOffset) { + const text = redactor.redact(collectText(history, store.frontier, history.nextOffset)); + store.tail = { upTo: history.nextOffset, text, newlines: countNewlines(text) }; + } + } else store.tail = null; + const parts: string[] = []; + for (let index = store.head; index < store.segments.length; index++) parts.push(store.segments[index]!.text); + if (store.tail?.text) parts.push(store.tail.text); + return { parts, start: store.viewStart, line: store.lineStart, store }; +} + +/** Where masking may begin: at the retained start, or after its first line break when older output was cut off. */ +function freshStore(history: History): MaskedStore { + let frontier = history.baseOffset; + let lineStart = history.baseLine; + if (history.baseOffset > 0 && history.head) { + // Pruning cuts at arbitrary characters; a key cut in half there would no longer be recognised. + const lookahead = collectText(history, history.baseOffset, Math.min(history.nextOffset, history.baseOffset + 65_536)); + const newline = lookahead.indexOf("\n"); + if (newline >= 0) { + frontier += newline + 1; + lineStart += 1; + } + } + return { revision: secretRevision, segments: [], head: 0, frontier, viewStart: frontier, lineStart, tail: null, chars: 0 }; +} + +function windowChars(): number { return Math.max(MASK_WINDOW_CHARS, 2 * redactor.lookahead() + 65_536); } + +/** Drop other sessions' masked copies (oldest sessions first) while all copies exceed their budget. */ +function boundMaskedCopies(keep: History): void { + let total = 0; + for (const history of histories.values()) total += history.masked?.chars ?? 0; + for (const history of histories.values()) { + if (total <= MAX_MASKED_CHARS) return; + if (history === keep || !history.masked) continue; + total -= history.masked.chars; + history.masked = null; + } +} + +type Match = { sessionId: string; line: number; text: string; offset: number }; + +function search(query: unknown, requested: unknown): { matches: Match[]; prunedSessionIds: string[] } { + if (typeof query !== "string" || query.length > 200 || !query.trim() || query.includes("\n") || query.includes("\r")) return { matches: [], prunedSessionIds: [] }; + if (requested !== undefined && requested !== null && (!Array.isArray(requested) || requested.length > 256 || requested.some((id) => typeof id !== "string"))) throw new Error("Invalid terminal output search scope."); + const allowed = Array.isArray(requested) ? new Set(requested) : null; + const matches: Match[] = []; + const needle = query.toLocaleLowerCase(); + const asciiNeedle = ASCII_NEEDLE.test(needle); + const prunedSessionIds = [...histories].filter(([id, history]) => (!allowed || allowed.has(id)) && history.pruned).map(([id]) => id); + for (const [sessionId, history] of histories) { + if (allowed && !allowed.has(sessionId)) continue; + const view = maskedView(history); + boundMaskedCopies(history); + searchView(view.parts, view.line, needle, asciiNeedle, (line, text, offset) => { + matches.push({ sessionId, line, text, offset }); + return matches.length < 100; + }); + if (matches.length >= 100) break; + } + return { matches, prunedSessionIds }; +} + +/** + * Report each line holding the needle once, in order. Parts are scanned in place with a case-insensitive pattern; + * no line or whole-session copy is made except for parts that hold characters whose lower case differs from what + * the pattern folds (they take the line-by-line path). + */ +function searchView(parts: string[], firstLine: number, needle: string, asciiNeedle: boolean, report: (line: number, text: string, offset: number) => boolean): void { + if (!asciiNeedle || parts.some(part => ASCII_FOLDING_CHARS.test(part))) { + // Unicode folding (and the few non-ASCII letters that fold to ASCII) goes line by line, as before. + forEachLine(parts, (text, line, offset) => { + const folded = asciiNeedle && !ASCII_FOLD_CHARS.test(text) ? text : text.toLocaleLowerCase(); + return !folded.includes(needle) || report(line, text.slice(0, 500), offset); + }, firstLine); + return; + } + const pattern = new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gi"); + const starts: number[] = []; + let total = 0; + for (const part of parts) { starts.push(total); total += part.length; } + let line = firstLine; + let counted = 0; + let lastLineStart = -1; + const reportAt = (position: number): boolean => { + if (position > counted) { + line += countNewlinesIn(parts, starts, counted, position); + counted = position; + } + const lineStart = lineStartAt(parts, starts, position); + if (lineStart === lastLineStart) return true; + lastLineStart = lineStart; + const lineEnd = lineEndAt(parts, starts, position, total); + return report(line, sliceParts(parts, starts, lineStart, Math.min(lineEnd, lineStart + 500)), lineStart); + }; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]!; + const base = starts[index]!; + pattern.lastIndex = 0; + for (let found = pattern.exec(part); found; found = pattern.exec(part)) { + if (!reportAt(base + found.index)) return; + // One result per line: continue after this line. + const next = part.indexOf("\n", found.index); + if (next < 0) break; + pattern.lastIndex = next + 1; + } + // A match may run over the joint with the next part. + if (index + 1 < parts.length && needle.length > 1) { + const from = Math.max(base, base + part.length - needle.length + 1); + const at = sliceParts(parts, starts, from, Math.min(total, base + part.length + needle.length - 1)).toLowerCase().indexOf(needle); + if (at >= 0 && from + at < base + part.length && !reportAt(from + at)) return; + } + } +} + +function partIndexAt(starts: number[], position: number): number { + let low = 0; + let high = starts.length - 1; + while (low < high) { + const middle = (low + high + 1) >> 1; + if (starts[middle]! <= position) low = middle; + else high = middle - 1; + } + return low; +} + +function lineStartAt(parts: string[], starts: number[], position: number): number { + let index = partIndexAt(starts, position); + let local = position - starts[index]! - 1; + for (; index >= 0; index--) { + if (local >= 0) { + const found = parts[index]!.lastIndexOf("\n", local); + if (found >= 0) return starts[index]! + found + 1; + } + if (index > 0) local = parts[index - 1]!.length - 1; + } + return 0; +} + +function lineEndAt(parts: string[], starts: number[], position: number, total: number): number { + for (let index = partIndexAt(starts, position); index < parts.length; index++) { + const part = parts[index]!; + const found = part.indexOf("\n", Math.max(0, position - starts[index]!)); + if (found >= 0) return starts[index]! + found; + } + return total; +} + +function sliceParts(parts: string[], starts: number[], from: number, to: number): string { + if (to <= from) return ""; + const pieces: string[] = []; + for (let index = partIndexAt(starts, from); index < parts.length && starts[index]! < to; index++) { + const part = parts[index]!; + pieces.push(part.slice(Math.max(0, from - starts[index]!), Math.min(part.length, to - starts[index]!))); + } + return pieces.join(""); +} + +function countNewlinesIn(parts: string[], starts: number[], from: number, to: number): number { + if (to <= from) return 0; + let count = 0; + for (let index = partIndexAt(starts, from); index < parts.length && starts[index]! < to; index++) { + const part = parts[index]!; + count += countNewlines(part, Math.max(0, from - starts[index]!), Math.min(part.length, to - starts[index]!)); + } + return count; +} + +function context(sessionId: unknown, offset: unknown): { text: string; firstLine: number; targetLine: number; historyTruncated: boolean; historyBaseOffset: number } { + if (typeof sessionId !== "string" || !Number.isSafeInteger(offset) || (offset as number) < 0) throw new Error("Invalid terminal output context request."); + const history = histories.get(sessionId); + if (!history) return { text: "", firstLine: 0, targetLine: 0, historyTruncated: false, historyBaseOffset: 0 }; + const view = maskedView(history); + const starts: number[] = []; + let total = 0; + for (const part of view.parts) { starts.push(total); total += part.length; } + const target = Math.min(total, offset as number); + // The start of the line holding the character 4 000 before the target (a line break there counts as its end). + const start = total === 0 ? 0 : lineStartAt(view.parts, starts, Math.min(total, Math.max(0, target - 4_000) + 1)); + const end = Math.min(total, target + 4_000); + const firstLine = view.line + countNewlinesIn(view.parts, starts, 0, start); + const targetLine = firstLine + countNewlinesIn(view.parts, starts, start, target); + return { text: sliceParts(view.parts, starts, start, end), firstLine, targetLine, historyTruncated: history.pruned, historyBaseOffset: view.start }; +} + +/** Walk masked parts line by line; whole-session strings are never joined. */ +function forEachLine(parts: string[], visit: (text: string, line: number, offset: number) => boolean, firstLine: number): boolean { + let line = firstLine; + let lineStart = 0; + let pendingLine = ""; + for (const part of parts) { + let cursor = 0; + for (;;) { + const end = part.indexOf("\n", cursor); + if (end < 0) break; + const text = pendingLine + part.slice(cursor, end); + if (!visit(text, line, lineStart)) return false; + lineStart += text.length + 1; + line++; + pendingLine = ""; + cursor = end + 1; + } + pendingLine += part.slice(cursor); + } + if (pendingLine && !visit(pendingLine, line, lineStart)) return false; + return true; +} + +function countNewlines(text: string, start = 0, end = text.length): number { + let count = 0; + for (let at = text.indexOf("\n", start); at >= 0 && at < end; at = text.indexOf("\n", at + 1)) count++; + return count; +} + +// ---- crash-recovery spill ---- + +/** + * Output is also appended, encrypted with a key that exists only in the host's memory, to bounded files in a + * private directory. A restarted worker reads them back, so a crash does not lose the older searchable history. + * The files are useless once the host is gone; the host deletes them on quit and a fresh start wipes them. + */ +function configureSpill(directory: unknown, key: unknown, recover: boolean): void { + if (typeof directory !== "string" || !directory || !(key instanceof Uint8Array) || key.length !== 32) throw new Error("Invalid terminal history spill."); + spill = { root: directory, key: Buffer.from(key), files: [], chars: 0, sequence: 0, timer: null }; + mkdirSync(directory, { recursive: true, mode: 0o700 }); + if (!recover) { + for (const entry of readdirSync(directory)) rmSync(join(directory, entry), { recursive: true, force: true }); + return; + } + const files: Array<{ path: string; owner: string; sequence: number }> = []; + for (const owner of readdirSync(directory)) { + if (!/^[0-9a-f]{32}$/u.test(owner)) continue; + for (const name of readdirSync(join(directory, owner))) { + const match = /^(\d{1,15})\.bin$/u.exec(name); + if (match) files.push({ path: join(directory, owner, name), owner, sequence: Number(match[1]) }); + } + } + files.sort((left, right) => left.sequence - right.sequence); + for (const file of files) { + let chars = 0; + try { chars = replaySpillFile(file.path); } + catch { /* An unreadable or truncated file holds no recoverable output. */ } + spill.files.push({ path: file.path, chars, owner: file.owner }); + spill.chars += chars; + spill.sequence = Math.max(spill.sequence, file.sequence + 1); + } + for (const history of histories.values()) history.spill = null; // new output goes to new files + pruneSpill(); +} + +function replaySpillFile(path: string): number { + const data = readFileSync(path); + let chars = 0; + for (let at = 0; at + 32 <= data.length;) { + const length = data.readUInt32LE(at); + if (at + 32 + length > data.length) break; + const decipher = createDecipheriv("aes-256-gcm", spill!.key, data.subarray(at + 4, at + 16)); + decipher.setAuthTag(data.subarray(at + 16, at + 32)); + const plain = Buffer.concat([decipher.update(data.subarray(at + 32, at + 32 + length)), decipher.final()]); + at += 32 + length; + for (let entry = 0; entry < plain.length;) { + const headerEnd = plain.indexOf(10, entry); + if (headerEnd < 0) break; + const header = JSON.parse(plain.subarray(entry, headerEnd).toString("utf8")) as { id: string; s: number; p: number; a: AnsiState; w: boolean; n: number; g?: boolean }; + if (typeof header.id !== "string" || !Number.isSafeInteger(header.s) || !Number.isSafeInteger(header.p) || !Number.isSafeInteger(header.n)) break; + const text = plain.subarray(headerEnd + 1, headerEnd + 1 + header.n).toString(header.w ? "utf16le" : "latin1"); + entry = headerEnd + 1 + header.n; + chars += replayEntry(header, text); + } + } + return chars; +} + +function replayEntry(header: { id: string; s: number; p: number; a: AnsiState; g?: boolean }, text: string): number { + const history = historyFor(header.id); + if (header.s < history.nextOffset) return 0; + if (header.s > history.nextOffset) { + // Older output of this session was pruned before the crash. + if (!history.head && history.nextOffset === 0) { history.nextOffset = header.s; history.baseOffset = header.s; } + history.pruned = true; + } + if (header.g) history.pruned = true; + const spilling = spill; + spill = null; // replayed text is already on disk + try { if (text) appendText(header.id, history, text); } + finally { spill = spilling; } + history.rawEnd = Math.max(history.rawEnd, header.p); + history.ansiState = header.a; + return text.length; +} + +/** Queue a retained chunk's own bytes, with where it belongs; nothing is copied until the flush encrypts it. */ +function queueSpill(sessionId: string, history: History, chunk: Chunk): void { + if (!spill) return; + const state = history.spill ??= { directory: join(spill.root, ownerKey(sessionId)), file: null, fileChars: 0, queue: [], queuedChars: 0 }; + state.queue.push(Buffer.from(`${JSON.stringify({ id: sessionId, s: chunk.start, p: history.rawEnd, a: history.ansiState, w: chunk.wide, n: chunk.data.length, + ...(history.pruned ? { g: true } : {}) })}\n`, "utf8"), chunk.data); + state.queuedChars += chunk.length; + if (!spill.timer) { + spill.timer = setTimeout(flushSpill, SPILL_FLUSH_MS); + spill.timer.unref?.(); + } +} + +function flushSpill(): void { + if (!spill) return; + if (spill.timer) clearTimeout(spill.timer); + spill.timer = null; + for (const history of histories.values()) if (history.spill?.queue.length) flushHistorySpill(history.spill); +} + +/** One authenticated record per flush: length, IV, tag, then the queued entries (a header line and its bytes each). */ +function flushHistorySpill(state: SpillState): void { + if (!spill || !state.queue.length) return; + try { + if (!state.file || state.fileChars >= SPILL_FILE_CHARS) { + mkdirSync(state.directory, { recursive: true, mode: 0o700 }); + state.file = join(state.directory, `${spill.sequence++}.bin`); + state.fileChars = 0; + spill.files.push({ path: state.file, chars: 0, owner: state.directory }); + } + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", spill.key, iv); + const pieces: Buffer[] = [Buffer.alloc(4), iv, Buffer.alloc(16)]; + let length = 0; + for (const piece of state.queue) { + const encrypted = cipher.update(piece); + pieces.push(encrypted); + length += encrypted.length; + } + const final = cipher.final(); + pieces.push(final); + length += final.length; + pieces[0]!.writeUInt32LE(length); + pieces[2] = cipher.getAuthTag(); + appendFileSync(state.file, Buffer.concat(pieces), { mode: 0o600 }); + const entry = spill.files.find(file => file.path === state.file); + if (entry) entry.chars += state.queuedChars; + state.fileChars += state.queuedChars; + spill.chars += state.queuedChars; + } catch { + // Recovery is best effort; search keeps working from memory. + } + state.queue = []; + state.queuedChars = 0; + pruneSpill(); +} + +/** Keep about as much on disk as in memory: drop the oldest files first. */ +function pruneSpill(): void { + if (!spill) return; + while (spill.chars > MAX_GLOBAL_CHARS + SPILL_FILE_CHARS && spill.files.length > 1) { + const oldest = spill.files.shift()!; + spill.chars -= oldest.chars; + try { unlinkSync(oldest.path); } catch { /* already gone */ } + for (const history of histories.values()) if (history.spill?.file === oldest.path) history.spill.file = null; + } +} + +function dropSpill(sessionId: string): void { + if (!spill) return; + const directory = join(spill.root, ownerKey(sessionId)); + spill.files = spill.files.filter(file => { + if (!file.path.startsWith(`${directory}/`) && !file.path.startsWith(`${directory}\\`)) return true; + spill!.chars -= file.chars; + return false; + }); + try { rmSync(directory, { recursive: true, force: true }); } catch { /* best effort */ } +} + +function ownerKey(sessionId: string): string { return createHash("sha256").update(sessionId).digest("hex").slice(0, 32); } + +/** Strip ECMA-48 terminal controls with constant-space state across PTY chunks. */ +function stripAnsi(source: string, initialState: AnsiState): { text: string; state: AnsiState } { + // PTYs mostly emit ordinary text: keep the batch as it is when it holds no control at all. + const input = source; + if (initialState === "text" && !CONTROL_START.test(input)) return { text: input, state: "text" }; + if (initialState === "text") { + // Colour and cursor sequences are the bulk of controls: drop complete ones natively. When they were the only + // controls, this is exactly what the decoder below yields; otherwise it decodes the original batch. + const withoutCsi = input.replace(COMPLETE_CSI, ""); + if (!CONTROL_START.test(withoutCsi)) return { text: withoutCsi, state: "text" }; + } + let state = initialState; + const visible: string[] = []; + let cursor = 0; + while (cursor < input.length) { + if (state === "text") { + // Jump over ordinary text natively instead of visiting each character. + CONTROL_SCAN.lastIndex = cursor; + const found = CONTROL_SCAN.exec(input); + const at = found ? found.index : input.length; + if (at > cursor) visible.push(input.slice(cursor, at)); + if (!found) break; + const code = input.charCodeAt(at); + state = code === 0x1b ? "escape" : code === 0x9b ? "csi" : code === 0x9d ? "osc" : code === 0x9c ? "text" : "string"; + cursor = at + 1; + continue; + } + const code = input.charCodeAt(cursor++); + switch (state) { + case "escape": + if (code === 0x5b) state = "csi"; + else if (code === 0x5d) state = "osc"; + else if (code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) state = "string"; + else state = "text"; // Unsupported two-byte ESC controls are discarded. + break; + case "csi": + if (code >= 0x40 && code <= 0x7e) state = "text"; + break; + case "osc": + if (code === 0x07 || code === 0x9c) state = "text"; + else if (code === 0x1b) state = "osc-escape"; + break; + case "osc-escape": + if (code === 0x5c) state = "text"; + else state = code === 0x1b ? "osc-escape" : "osc"; + break; + case "string": + if (code === 0x9c) state = "text"; + else if (code === 0x1b) state = "string-escape"; + break; + case "string-escape": + if (code === 0x5c) state = "text"; + else state = code === 0x1b ? "string-escape" : "string"; + break; + } + } + return { text: visible.length === 1 ? visible[0]! : visible.join(""), state }; +} +const CONTROL_START = /[\u001b\u0090\u0098\u009b\u009c\u009d\u009e\u009f]/u; +/** ESC [ or CSI, parameter bytes, intermediate bytes, a final byte (ECMA-48 5.4). */ +const COMPLETE_CSI = /(?:\u001b\[|\u009b)[\u0030-\u003f]*[\u0020-\u002f]*[\u0040-\u007e]/gu; +const CONTROL_SCAN = /[\u001b\u0090\u0098\u009b\u009c\u009d\u009e\u009f]/gu; diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index 269c49bd..5c20289d 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -1,4 +1,4 @@ -import { dirname, join } from "node:path"; +import { dirname, join, isAbsolute, normalize } from "node:path"; import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; import type { LaunchProfileId, @@ -25,6 +25,9 @@ export const MAX_PLUGIN_SLOT_BYTES = 4_096; const MAX_OPTION_PLUGINS = 16; export interface PersistedTerminalSession { + /** Host-only launch history; missing evidence on a legacy placed card is conservatively unknown. */ + isolatedEnvironmentScopes?: {roots:string[];ambiguous:boolean}; + taskScope?:{id:string;cwd:string;startedAt:number}; id: string; provider: ProviderId; profile: LaunchProfileId; @@ -57,6 +60,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; } @@ -218,7 +222,7 @@ function normalizeStoredThreadId(provider: ProviderId, candidate: unknown): stri } /** What core keeps beside the live metadata: nothing here is scrollback, prompts or secrets. */ -export type PersistedSessionExtras = Pick & { +export type PersistedSessionExtras = Pick & { /** Overrides the derived state while a card is held stopped (its environment is unavailable). */ heldState?: PersistedLastState; }; @@ -242,20 +246,34 @@ 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 } : {}), restore: metadata.skipRestore !== true, + ...(extras.isolatedEnvironmentScopes ? {isolatedEnvironmentScopes:structuredClone(extras.isolatedEnvironmentScopes)} : {}), ...(extras.options ? { options: structuredClone(extras.options) } : {}), ...(extras.environment ? { environment: structuredClone(extras.environment) } : {}), ...(extras.environmentChoice && !extras.environment ? { environmentChoice: structuredClone(extras.environmentChoice) } : {}), ...(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 } : {}) }; } +/** Invalid or pre-evidence environment records must never silently regain local restoration authority. */ +function normalizeIsolationEvidence(value:unknown,placed:boolean):PersistedTerminalSession["isolatedEnvironmentScopes"] { + if(value===undefined)return placed ? {roots:[],ambiguous:true} : undefined; + const invalid={roots:[],ambiguous:true}; + if(!value || typeof value!=="object" || Array.isArray(value))return invalid; + const evidence=value as {roots?:unknown;ambiguous?:unknown}; + if(Object.keys(value).some(key=>key!=="roots" && key!=="ambiguous") || typeof evidence.ambiguous!=="boolean" || !Array.isArray(evidence.roots) || evidence.roots.length>32)return invalid; + if(evidence.roots.some(root=>typeof root!=="string" || root.length===0 || root.length>4096 || root.includes("\0") || !isAbsolute(root) || normalize(root)!==root))return invalid; + return {roots:[...new Set(evidence.roots as string[])],ambiguous:evidence.ambiguous}; +} + export function normalizePersistedTerminalSessions(candidate: unknown): PersistedTerminalSessionState { if (!candidate || typeof candidate !== "object") return structuredClone(EMPTY_STATE); const source = candidate as { version?: unknown; sessions?: unknown }; @@ -308,6 +326,7 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste // Likewise a launch whose environment was chosen but not prepared yet. const environmentChoice = environment ? undefined : normalizeEnvironmentChoice(session.environmentChoice); if (!environment && session.environmentChoice !== undefined && !environmentChoice) continue; + const isolation=normalizeIsolationEvidence(session.isolatedEnvironmentScopes,Boolean(environment)); sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -322,12 +341,14 @@ 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 } : {}), restore: session.restore !== false, ...(options ? { options } : {}), ...(environment ? { environment } : {}), + ...(isolation ? {isolatedEnvironmentScopes:isolation} : {}), ...(environmentChoice ? { environmentChoice } : {}), ...(isPluginId(session.ownerPluginId) ? { ownerPluginId: session.ownerPluginId } : {}), ...(typeof session.gitAuditSince === "number" && Number.isFinite(session.gitAuditSince) && session.gitAuditSince > 0 @@ -336,7 +357,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 +449,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 00000000..10d37280 --- /dev/null +++ b/src/main/services/TimelineIndexScan.ts @@ -0,0 +1,265 @@ +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; + /** Host index sequence of the last observation; rebuilt from journal order on load. */ + sampleOrder?: number; +}; +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, sampleOrder = 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,sampleOrder:sampleOrder++}); + 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; +} + +/** A card may use the same provider counter name under different accounts. */ +export function timelineUsageKey(sessionId:string,row:Pick):string { + return JSON.stringify([sessionId,row.source,row.counterId ?? "",row.provider ?? "",row.accountId ?? ""]); +} + +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=timelineUsageKey(event.sessionId,row); + 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 00000000..66152bea --- /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 00000000..c418fbf0 --- /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 00000000..2da88ec3 --- /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 model=row.model.trim(),key=`${row.provider}:${model}`;if(keys.has(key))throw new Error("Duplicate model price.");keys.add(key); + return {provider:row.provider,model,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 00000000..fec140bd --- /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 ba6afebf..4db4c8b1 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,12 @@ 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.isolate === "worktree" ? { isolate: "worktree" as const } : {}), + ...(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 +319,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 +333,10 @@ 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 } : {}), + ...(args.isolate === "worktree" ? { isolationRequested: "worktree" } : {}), }; } @@ -213,9 +372,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 +382,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 +420,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 18b6ef2d..5df176bb 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 66387516..a0f25058 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({ @@ -406,6 +408,7 @@ const CLAUDE_HOOKS: readonly HookMapping[] = [ { event: "UserPromptSubmit", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PostToolUse", state: "working" }, + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "SessionEnd", state: "idle" }, @@ -438,6 +441,8 @@ const KIMI_HOOKS: readonly HookMapping[] = [ { event: "TurnStarted", state: "working" }, { event: "PermissionRequest", state: "needs_approval" }, { event: "PermissionResult", state: "working" }, + { event: "PostToolUse", state: "working" }, + { event: "PostToolUseFailure", state: "working" }, { event: "Stop", state: "idle" }, { event: "StopFailure", state: "idle" }, { event: "Interrupt", state: "idle" }, @@ -449,6 +454,7 @@ const HERMES_HOOKS: readonly HookMapping[] = [ { event: "pre_llm_call", state: "working" }, { event: "pre_approval_request", state: "needs_approval" }, { event: "post_approval_response", state: "working" }, + { event: "post_tool_call", state: "working" }, { event: "on_session_end", state: "idle" } ]; @@ -641,11 +647,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 7ad4f9d4..0ad765bd 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -16,7 +16,8 @@ import { PERMISSION_GATE, permissionGateTimings, RUNTIME_PROTOCOL_VERSION, - RUNTIME_STATES + RUNTIME_STATES, + toolOutcomeFromHook } from "../../../agent-runtime/runtime-protocol.mjs"; import { NdjsonLineReader } from "../../../agent-runtime/ndjson.mjs"; import { @@ -60,13 +61,27 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + /** Host-owned turn generation; advances once per accepted start, including providers without turn ids. */ + turnEpoch: number; /** The provider's own conversation id (Codex thread, Claude or OpenCode session), as its hook reported it. */ threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; + /** Hash-only summary of an actual completed tool hook. */ + toolOutcome?: RuntimeToolOutcome; answerCaptureGrantExpiresAt?: number; } +export interface RuntimeToolOutcome { + toolName: string; + resultClass: "success" | "error" | "denied" | "unknown"; + normalizedActionHash?: string; + errorHash?: string; + /** Hash of the tool's output for non-error results; lets loop detection see repeated output. */ + outputHash?: string; + changedPathHashes: string[]; +} + export interface RuntimeSessionCapability { address: string; terminalSessionId: string; @@ -79,6 +94,8 @@ interface RuntimeLease { provider: Exclude; tokenDigest: Buffer; activeTurnId: string | null; + turnEpoch: number; + turnOpen: boolean; /** Turn ids this session already reported (bounded, oldest dropped): a late start of one of them is stale. */ seenTurnIds: Set; latest: RuntimeLifecycleSignal | null; @@ -89,6 +106,8 @@ interface RuntimeLease { /** How long a decision may take for this session (sized at launch from the decision services' budgets). */ gatewayMs: number; checks: Set; + lifecycleChecks: Set; + lifecycleRevision: number; } /** One decision hook call (permission-gate.mjs). The tool input is agent-influenced data, never instructions. */ @@ -103,6 +122,10 @@ export interface RuntimePermissionRequest { truncated: boolean; /** The agent's current folder as its CLI reported it. */ cwd: string | null; + /** Host-derived from the authenticated session's active lifecycle turn, never trusted from the permission packet. */ + turnId?: string; + /** Host-owned generation for providers whose lifecycle hooks omit turn ids. */ + turnEpoch: number; } /** `none`: no opinion, the CLI goes on as it would without CanvasTTY. */ @@ -121,6 +144,7 @@ interface ParsedLifecycleMessage { threadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; + toolOutcome?: RuntimeToolOutcome; } export interface RuntimeGatewayOptions { @@ -130,6 +154,12 @@ export interface RuntimeGatewayOptions { windowsPipeHostFactory?: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; /** A connection must send its one message within this time (default 5 s). */ firstMessageTimeoutMs?: number; + /** Captures a synchronous host freshness token, checked even if checkpoint cancellation settles later. */ + lifecycleGuard?(terminalSessionId: string, signal: RuntimeLifecycleSignal): () => boolean; + /** Barrier for authenticated working events only; UI delivery stays asynchronous. Failures/timeouts fail open. */ + beforeLifecycle?(terminalSessionId: string, signal: RuntimeLifecycleSignal, cancellation: AbortSignal): Promise | boolean | void; + /** Must remain below the unchanged 1 s JS/native hook deadline. Tests may shorten it. */ + lifecycleBarrierMs?: number; onSignal?(terminalSessionId: string, signal: RuntimeLifecycleSignal): void; onAnswerCaptureRevoked?(terminalSessionId: string): void; /** @@ -150,6 +180,9 @@ export class RuntimeGateway { private readonly requestedRuntimeDirectory: string | undefined; private readonly windowsHostPath: string | undefined; private readonly windowsPipeHostFactory: (options: WindowsPipeHostTransportOptions) => WindowsPipeHostTransport; + private readonly lifecycleGuard: RuntimeGatewayOptions["lifecycleGuard"]; + private readonly beforeLifecycle: RuntimeGatewayOptions["beforeLifecycle"]; + private readonly lifecycleBarrierMs: number; private readonly onSignal: RuntimeGatewayOptions["onSignal"]; private readonly onAnswerCaptureRevoked: RuntimeGatewayOptions["onAnswerCaptureRevoked"]; private readonly onPermissionRequest: RuntimeGatewayOptions["onPermissionRequest"]; @@ -157,6 +190,7 @@ export class RuntimeGateway { private readonly firstMessageTimeoutMs: number; private readonly checks = new Set(); private readonly leases = new Map(); + private nextTurnEpoch = 0; private readonly sockets = new Set(); private closed = false; private restartTimer: ReturnType | undefined; @@ -179,6 +213,9 @@ export class RuntimeGateway { ?? ((transportOptions) => new WindowsPipeHostTransport(transportOptions)); this.firstMessageTimeoutMs = options.firstMessageTimeoutMs ?? FIRST_MESSAGE_TIMEOUT_MS; this.onSignal = options.onSignal; + this.beforeLifecycle = options.beforeLifecycle; + this.lifecycleGuard = options.lifecycleGuard; + this.lifecycleBarrierMs = Math.max(1, Math.min(750, options.lifecycleBarrierMs ?? 750)); this.onAnswerCaptureRevoked = options.onAnswerCaptureRevoked; this.onPermissionRequest = options.onPermissionRequest; this.now = options.now ?? Date.now; @@ -276,6 +313,8 @@ export class RuntimeGateway { provider, tokenDigest: tokenDigest(capabilityToken), activeTurnId: null, + turnEpoch: 0, + turnOpen: false, seenTurnIds: new Set(), latest: null, captureResult: captureResultOrGrantExpiresAt === true, @@ -287,6 +326,7 @@ export class RuntimeGateway { : null, decisions, gatewayMs: permissionGateTimings(decisionBudgetMs).gatewayMs, + lifecycleChecks: new Set(), lifecycleRevision: 0, checks: new Set() }); return { address: this.endpoint, terminalSessionId, provider, capabilityToken }; @@ -296,6 +336,13 @@ export class RuntimeGateway { return this.leases.get(terminalSessionId)?.latest?.state ?? null; } + /** Current host-owned turn generation for a live hook/permission stream; null between turns or after revoke. */ + currentTurnEpoch(terminalSessionId: string): number | null { + const lease = this.leases.get(terminalSessionId); + if (!lease?.turnOpen) return null; + return lease.turnEpoch; + } + /** * Ends a session's lease. A launch's own cleanup passes its capability token, so a cleanup that runs * late (after the card was relaunched under the same id) cannot revoke the newer launch's lease. @@ -307,6 +354,7 @@ export class RuntimeGateway { lease.tokenDigest.fill(0); this.leases.delete(terminalSessionId); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(terminalSessionId); } @@ -342,6 +390,7 @@ export class RuntimeGateway { for (const lease of this.leases.values()) { lease.tokenDigest.fill(0); for (const check of lease.checks) check.abort(); + for (const check of lease.lifecycleChecks) check.abort(); if (lease.answerCaptureGrantExpiresAt !== null) { this.onAnswerCaptureRevoked?.(lease.terminalSessionId); } @@ -406,10 +455,18 @@ export class RuntimeGateway { answerCapture })}\n`, "utf8")); } else { - // The ack goes out before the app reacts: the hook (and the agent behind it) waits only for the check. const delivery = this.handleLifecycle(value); - socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + socket.write(Buffer.from(`${JSON.stringify({ v: RUNTIME_PROTOCOL_VERSION, type: "ack" })}\n`, "utf8")); + this.deliverLater(accepted); + const timeout = setTimeout(close, 1_000); timeout.unref(); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") { + void this.waitBeforeLifecycle(delivery, socket).then(acknowledge).catch(close); + return; + } + acknowledge(delivery); + return; } const timeout = setTimeout(close, 1_000); timeout.unref(); @@ -469,40 +526,96 @@ export class RuntimeGateway { throw new Error("Answer capture is not authorized for this session."); } - if (message.turnId && isTurnStart(message.event)) { + const startsTurn = isTurnStart(message.event); + if (message.turnId && startsTurn) { // Each hook runs on its own connection, so a start of an earlier turn can arrive after the next turn began; - // it must not make that newer turn's events look stale. + // it must not make that newer turn's events look fresh. A duplicate start for the current open turn is inert. if (message.turnId !== lease.activeTurnId && lease.seenTurnIds.has(message.turnId)) return null; + if (message.turnId === lease.activeTurnId && !lease.turnOpen && lease.seenTurnIds.has(message.turnId)) return null; + if (message.turnId !== lease.activeTurnId) lease.turnEpoch = ++this.nextTurnEpoch; lease.activeTurnId = message.turnId; + lease.turnOpen = true; rememberTurn(lease, message.turnId); - } else if ( - message.turnId - && lease.activeTurnId - && message.turnId !== lease.activeTurnId - ) { - return null; - } else if (message.turnId) { + } else if (message.turnId && lease.activeTurnId && message.turnId !== lease.activeTurnId) { + // A previously unseen id after an idle boundary can establish the current turn even if its start hook was + // unavailable; an old id or a conflicting id during an open turn is stale/out of order. + if (lease.turnOpen || lease.seenTurnIds.has(message.turnId)) return null; + lease.activeTurnId = message.turnId; + lease.turnEpoch = ++this.nextTurnEpoch; + lease.turnOpen = true; rememberTurn(lease, message.turnId); + } else if (message.turnId && !lease.activeTurnId) { + if (!lease.turnOpen) lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = message.turnId; + lease.turnOpen = true; + rememberTurn(lease, message.turnId); + } else if (!message.turnId && startsTurn) { + // With no provider id, only the first start while closed opens a generation. Approval/resume events and + // duplicate start hooks while a turn is open leave its identity unchanged. + if (!lease.turnOpen) { + lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = null; + } + lease.turnOpen = true; } + if (message.state === "idle") lease.turnOpen = false; const signal: RuntimeLifecycleSignal = { state: message.state, event: message.event, turnId: message.turnId, + turnEpoch: lease.turnEpoch, ...(message.threadId === undefined ? {} : { threadId: message.threadId }), ...(message.result === undefined ? {} : { result: message.result }), - ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) + ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }), + ...(message.toolOutcome === undefined ? {} : { toolOutcome: message.toolOutcome }) }; if (message.lastAssistantMessage !== undefined && lease.answerCaptureGrantExpiresAt !== null) { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } + // A newer accepted signal supersedes an in-flight pre-turn barrier, including its snapshot work. + for (const check of lease.lifecycleChecks) check.abort(); + lease.lifecycleRevision++; // Captured text is delivered once and never stored in the lifecycle lease. lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId, + turnEpoch: signal.turnEpoch, ...(signal.threadId === undefined ? {} : { threadId: signal.threadId }) }; - return { terminalSessionId: message.terminalSessionId, signal }; + return { terminalSessionId: message.terminalSessionId, signal, lease, revision: lease.lifecycleRevision }; + } + + private async waitBeforeLifecycle(delivery: Delivery, connection: { on(event: "close", listener: () => void): unknown }): Promise { + const controller = new AbortController(), { lease } = delivery; + const hostCurrent=this.lifecycleGuard?.(delivery.terminalSessionId,delivery.signal) ?? (()=>true); + const current = (): boolean => this.leases.get(delivery.terminalSessionId) === lease && lease.lifecycleRevision === delivery.revision && hostCurrent(); + delivery.current=()=>this.leases.get(delivery.terminalSessionId) === lease && hostCurrent(); + let listening = true; + const abort = (): void => { if (listening) controller.abort(); }; + connection.on("close", abort); + lease.lifecycleChecks.add(controller); + let cancel!: () => void; + const cancelled = new Promise(resolve => { cancel = resolve; }); + controller.signal.addEventListener("abort", cancel, { once: true }); + const timeout = setTimeout(abort, this.lifecycleBarrierMs); + timeout.unref(); + try { + if (!current()) return null; + const work = Promise.resolve().then(() => this.beforeLifecycle?.(delivery.terminalSessionId, delivery.signal, controller.signal)); + const accepted = await Promise.race([work, cancelled]); + // The callback must observe cancellation before publishing a checkpoint. A timeout remains fail-open. + if (controller.signal.aborted) console.warn("Pre-turn checkpoint unavailable: lifecycle barrier cancelled or timed out."); + return accepted !== false && current() ? delivery : null; + } catch (error) { + console.warn("Pre-turn checkpoint unavailable:", error instanceof Error ? error.message : String(error)); + return current() ? delivery : null; + } finally { + clearTimeout(timeout); + listening = false; + controller.signal.removeEventListener("abort", cancel); + lease.lifecycleChecks.delete(controller); + } } /** Runs the app's reaction after the current I/O callback, in arrival order; a failure there never reaches a hook. */ @@ -510,6 +623,7 @@ export class RuntimeGateway { const onSignal = this.onSignal; if (!delivery || !onSignal) return; setImmediate(() => { + if(delivery.current && !delivery.current())return; try { onSignal(delivery.terminalSessionId, delivery.signal); } catch (error) { @@ -605,16 +719,17 @@ export class RuntimeGateway { } catch { delivery = null; } - // An oversized body is still being sent: answer once it has arrived (read and dropped), so the connection is - // closed after it rather than reset under the sender, which would lose the answer (ECONNRESET). - if (oversized && !request.complete) { - request.resume(); - request.once("end", () => finish(200, true)); - request.once("close", () => finish(200, true)); - } else { - finish(200, oversized); - } - this.deliverLater(delivery); + const acknowledge = (accepted: Delivery | null): void => { + // Drain an oversized request before closing its connection, preserving the helper's fail-open contract. + if (oversized && !request.complete) { + request.resume(); + request.once("end", () => finish(200, true)); + request.once("close", () => finish(200, true)); + } else finish(200, oversized); + this.deliverLater(accepted); + }; + if (this.beforeLifecycle && delivery?.signal.state === "working") void this.waitBeforeLifecycle(delivery, response).then(acknowledge); + else acknowledge(delivery); }; if (oversized) return complete(); request.on("data", (chunk: Buffer) => { @@ -642,7 +757,7 @@ export class RuntimeGateway { * `unavailable`, so a fail-closed gate for a CLI that cannot ask denies it instead of letting the call run. */ private acceptPermission(socket: AgentGatewaySocket, value: Record, close: () => void): void { - let request: RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; + let request: Omit & { terminalSessionId: string; capabilityToken: string }; try { request = parsePermissionMessage(value); } catch { @@ -651,7 +766,18 @@ export class RuntimeGateway { const lease = this.leases.get(request.terminalSessionId); if (!lease || lease.provider !== request.provider) return close(); if (!tokenMatches(request.capabilityToken, lease.tokenDigest) || !lease.decisions) return close(); - const { terminalSessionId, capabilityToken: _token, ...forwarded } = request; + const { terminalSessionId, capabilityToken: _token, turnId: _untrustedTurnId, ...forwardedFields } = request; + if (!lease.turnOpen) { + // Some providers report only tool permissions. Their first real permission hook opens a host turn; an + // untrusted turn id in the permission packet is never used to create or identify that turn. + lease.turnEpoch = ++this.nextTurnEpoch; + lease.activeTurnId = null; + lease.turnOpen = true; + } + const turnId = lease.activeTurnId && /^[A-Za-z0-9._:-]{1,160}$/u.test(lease.activeTurnId) + ? lease.activeTurnId + : undefined; + const forwarded: RuntimePermissionRequest = { ...forwardedFields, turnEpoch: lease.turnEpoch, ...(turnId ? { turnId } : {}) }; let answered = false; const answer = (decision: RuntimePermissionDecision, unavailable = false): void => { if (answered) return; @@ -700,6 +826,9 @@ export class RuntimeGateway { } interface Delivery { + current?: () => boolean; + lease: RuntimeLease; + revision: number; terminalSessionId: string; signal: RuntimeLifecycleSignal; } @@ -743,6 +872,7 @@ function claudeLifecycleMessage( const text = boundedText(finalAnswer, MAX_RESULT_CHARS); result = { text, truncated: text.length < finalAnswer.length }; } + const toolOutcome = toolOutcomeFromHook("claude", event, record); return { terminalSessionId, provider: "claude", @@ -750,7 +880,8 @@ function claudeLifecycleMessage( event, turnId: turnId !== null && turnId.length <= 160 ? turnId : null, ...(threadId !== undefined ? { threadId } : {}), - ...(result === undefined ? {} : { result }) + ...(result === undefined ? {} : { result }), + ...(toolOutcome === undefined ? {} : { toolOutcome }) }; } @@ -793,8 +924,8 @@ const PERMISSION_KEYS = [ "toolInputPreview", "toolInputSha256", "toolName", "truncated", "type", "v" ].sort().join(","); -function parsePermissionMessage(value: Record): RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string } { - if (Object.keys(value).sort().join(",") !== PERMISSION_KEYS) throw new Error("Permission request has an invalid schema."); +function parsePermissionMessage(value: Record): Omit & { terminalSessionId: string; capabilityToken: string } { + if (Object.keys(value).filter((key) => key !== "turnId").sort().join(",") !== PERMISSION_KEYS) throw new Error("Permission request has an invalid schema."); if (value.v !== RUNTIME_PROTOCOL_VERSION || value.type !== "permission_request") throw new Error("Permission request version is unsupported."); if ( typeof value.terminalSessionId !== "string" || !value.terminalSessionId || value.terminalSessionId.length > 160 @@ -805,6 +936,7 @@ function parsePermissionMessage(value: Record): RuntimePermissi || typeof value.toolInputSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(value.toolInputSha256) || typeof value.truncated !== "boolean" || (value.cwd !== null && (typeof value.cwd !== "string" || !value.cwd || value.cwd.length > 4_096)) + || (value.turnId !== undefined && value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) || (value.toolInputPreview !== null && (typeof value.toolInputPreview !== "string" || value.toolInputPreview.length > PERMISSION_GATE.toolInputPreviewChars)) ) throw new Error("Permission request fields are invalid."); // Cut input carries only its preview; whole input carries no preview and must match its hash. @@ -814,7 +946,10 @@ function parsePermissionMessage(value: Record): RuntimePermissi if (!value.truncated && createHash("sha256").update(JSON.stringify(value.toolInput), "utf8").digest("hex") !== value.toolInputSha256) { throw new Error("Permission request input does not match its hash."); } - return value as unknown as RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; + // A hook may claim a turn identifier, but only authenticated lifecycle messages update the lease. Drop this + // untrusted field here; acceptPermission supplies the host's active turn when it forwards the request. + const { turnId: _untrustedTurnId, ...trustedFields } = value; + return trustedFields as unknown as RuntimePermissionRequest & { terminalSessionId: string; capabilityToken: string }; } function isAnswerCaptureCheck(value: unknown): value is Record { @@ -834,6 +969,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { ]; if (value.result !== undefined) expected.push("result"); if (value.threadId !== undefined) expected.push("threadId"); + if (value.toolOutcome !== undefined) expected.push("toolOutcome"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -870,14 +1006,47 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { value.provider !== "codex" || value.event !== "Stop" || value.state !== "idle" || typeof value.lastAssistantMessage !== "string" || value.lastAssistantMessage.length > MAX_ANSWER_CHARS )) throw new Error("Runtime lastAssistantMessage is invalid."); + if (value.toolOutcome !== undefined) { + if (!isCompletedToolEvent(String(value.provider), String(value.event))) throw new Error("Runtime tool outcome event is invalid."); + parseToolOutcome(value.toolOutcome); + } return value as unknown as ParsedLifecycleMessage; } +function parseToolOutcome(value: unknown): RuntimeToolOutcome { + if (!isRecord(value)) throw new Error("Runtime tool outcome must be an object."); + const keys = Object.keys(value).sort(); + const expected = ["changedPathHashes", "resultClass", "toolName"]; + if (value.normalizedActionHash !== undefined) expected.push("normalizedActionHash"); + if (value.errorHash !== undefined) expected.push("errorHash"); + if (value.outputHash !== undefined) expected.push("outputHash"); + expected.sort(); + if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { + throw new Error("Runtime tool outcome has an invalid schema."); + } + if (typeof value.toolName !== "string" || value.toolName.length === 0 || value.toolName.length > 80 + || !["success", "error", "denied", "unknown"].includes(String(value.resultClass)) + || !Array.isArray(value.changedPathHashes) || value.changedPathHashes.length > 16 + || !value.changedPathHashes.every((hash) => typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash)) + || (value.normalizedActionHash !== undefined && (typeof value.normalizedActionHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.normalizedActionHash))) + || (value.errorHash !== undefined && (value.resultClass !== "error" || typeof value.errorHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.errorHash))) + || (value.outputHash !== undefined && (value.resultClass === "error" || typeof value.outputHash !== "string" || !/^[a-f0-9]{64}$/u.test(value.outputHash))) + ) throw new Error("Runtime tool outcome fields are invalid."); + return value as unknown as RuntimeToolOutcome; +} + +function isCompletedToolEvent(provider: string, event: string): boolean { + return event === "PostToolUse" || ((provider === "claude" || provider === "kimi") && event === "PostToolUseFailure") + || (provider === "hermes" && event === "post_tool_call") + || (provider === "opencode" && (event === "tool.execute.after" || event === "message.part.updated")); +} + 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 b114cbf4..15ee3ceb 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/companion/CompanionSessions.ts b/src/main/services/companion/CompanionSessions.ts index b10729f3..792337b9 100644 --- a/src/main/services/companion/CompanionSessions.ts +++ b/src/main/services/companion/CompanionSessions.ts @@ -27,6 +27,8 @@ export interface CompanionHost { providers?(): Record; read(sessionId: string): Promise; input(sessionId: string, data: string): boolean; + /** Called once after a successful structured text submission, never for keys or interrupts. */ + inputSubmitted?(sessionId: string): void; close(sessionId: string): void; rename(sessionId: string, title: string): CompanionSession; create(provider: ProviderId): CompanionSession; @@ -207,6 +209,7 @@ export class CompanionSessions { : `\x1b[200~${action.text}\x1b[201~\r`; if (!this.host.input(action.sessionId, data)) throw new CompanionError("unavailable"); + if (action.type === "session.input") this.host.inputSubmitted?.(action.sessionId); return { delivered: true, sessionId: action.sessionId }; } } diff --git a/src/main/services/companion/EvenG2Controller.ts b/src/main/services/companion/EvenG2Controller.ts index 1a913861..935eecb9 100644 --- a/src/main/services/companion/EvenG2Controller.ts +++ b/src/main/services/companion/EvenG2Controller.ts @@ -1,3 +1,4 @@ +import type { AttentionEvent, NotificationChannel } from "../../../shared/backlog.ts"; import { createServer, type Server, @@ -11,7 +12,7 @@ import { join, resolve, extname } from "node:path"; import { isPathInside } from "../../../agent-runtime/path-inside.mjs"; import { hostname } from "node:os"; import type { TerminalManager } from "../TerminalManager.ts"; -import type { LimitsSnapshot, ProviderId } from "../../../shared/contracts.ts"; +import type { LimitsSnapshot, ProviderId, SessionMetadata } from "../../../shared/contracts.ts"; import { CANVAS_LAUNCHER_ITEMS } from "../../../shared/providerCatalog.ts"; import { CompanionError, @@ -160,8 +161,12 @@ export class EvenG2Controller { private pairingDiagnostics: string[] = []; private diagnosticsWrite = Promise.resolve(); private limits: () => Promise; + private readonly loopWarningActive:(id:string)=>boolean; + private readonly notifications:(channel:NotificationChannel,id:string)=>AttentionEvent[]; constructor(options: { + loopWarningActive?: (sessionId:string)=>boolean; + notifications?: (channel:NotificationChannel,sessionId:string)=>AttentionEvent[]; userDataPath: string; terminals: Terminals; webRoot: string; @@ -194,6 +199,8 @@ export class EvenG2Controller { }) : null; this.terminals = options.terminals; + this.notifications=options.notifications ?? (()=>[]); + this.loopWarningActive=options.loopWarningActive ?? (()=>false); this.mobileRoot = options.mobileRoot ?? null; this.webRoot = options.webRoot; this.port = options.port ?? 3481; @@ -222,6 +229,7 @@ export class EvenG2Controller { if (written) this.presentation.pending(id); return written; }, + inputSubmitted: (id) => this.presentation.submitted(id), close: (id) => this.terminals.dispose(id), rename: (id, title) => safeSession(this.terminals.rename(id, title)), create: (provider) => { @@ -310,6 +318,20 @@ export class EvenG2Controller { } } } + /** History remains available; the glasses notice describes only a still-current condition. */ + private currentAttention(session:SessionMetadata,selectionSent:boolean):AttentionEvent|null { + return [...this.notifications("glasses",session.id)].reverse().find(event=>{ + if(event.sessionId!==session.id)return false; + switch(event.kind) { + case "approval": return session.status==="needs_approval" && !selectionSent; + case "response": return session.status==="idle" && !session.turnCompleted && this.presentation.canShowResponseAttention(session.id); + case "done": return session.status==="done" || session.status==="idle" && session.turnCompleted===true; + case "failed": return session.status==="failed"; + case "budget": return session.taskBudget?.warning===true || session.taskBudget?.paused===true; + case "loop": return session.exitCode===null && this.loopWarningActive(session.id); + } + }) ?? null; + } observe(channel: string, payload: unknown): void { if (this.config.enabled) this.presentation.observe(channel, payload); } @@ -1046,7 +1068,7 @@ export class EvenG2Controller { this.access.assertCurrent(grant); const session = this.terminals.listMetadata().find((s) => s.id === id); if (!session) return this.json(res, 404, { error: "not-found" }); - return this.json(res, 200, { session: safeSession(session), ...view }); + return this.json(res, 200, { session: {...safeSession(session),title:this.terminals.redactSecrets(session.title)}, ...view,attention:this.currentAttention(session,view.revision.startsWith("selected-")) }); } if (req.method !== "POST") return this.json(res, 404, { error: "not-found" }); @@ -1197,7 +1219,7 @@ export class EvenG2Controller { ) ) throw new Error("terminal-closed"); - this.presentation.pending(id); + this.presentation.submitted(id); return true; }, ), diff --git a/src/main/services/companion/TerminalPresentation.ts b/src/main/services/companion/TerminalPresentation.ts index 98238829..05a66eed 100644 --- a/src/main/services/companion/TerminalPresentation.ts +++ b/src/main/services/companion/TerminalPresentation.ts @@ -44,6 +44,9 @@ interface Screen { authoritative: boolean; sequence: number; turnPending: boolean; + inputPending: boolean; + observedStatus: SessionMetadata["status"] | null; + responseAvailable: boolean; busySeen: boolean; menuFingerprint: string | null; menuId: string | null; @@ -71,6 +74,9 @@ export class TerminalPresentation { authoritative: false, sequence: 0, turnPending: false, + inputPending: false, + observedStatus: null, + responseAvailable: false, busySeen: false, menuFingerprint: null, menuId: null, @@ -125,11 +131,7 @@ export class TerminalPresentation { } if (channel === IPC.terminalSession) { const session = (payload as { session: SessionMetadata }).session; - const screen = this.screen(session.id); - if (session.status === "working") { - screen.turnPending = true; - screen.busySeen = true; - } + this.observeStatus(this.screen(session.id),session); return; } if (channel !== IPC.terminalData) return; @@ -160,6 +162,21 @@ export class TerminalPresentation { new Promise((resolve) => terminal.write(data, resolve)), ); } + private observeStatus(screen:Screen,session:SessionMetadata):void { + const previous=screen.observedStatus; + screen.observedStatus=session.status; + if(session.status==="working") { + screen.turnPending=true;screen.busySeen=true;screen.responseAvailable=false; + } else if(session.status==="idle" && !session.turnCompleted) { + if(previous===null || previous==="working")screen.responseAvailable=true; + if(previous==="working")screen.inputPending=false; + } else screen.responseAvailable=false; + } + /** An old response is eligible again only after fresh progress/answer, not a failed-to-idle transition. */ + canShowResponseAttention(id:string):boolean { + const screen=this.screens.get(id); + return screen?.responseAvailable===true && !screen.inputPending; + } answer(id: string, text: string, turnId: string | null, expiresAt: number): void { if (!this.port.listMetadata().some((s) => s.id === id) || !text.trim()) return; @@ -172,6 +189,8 @@ export class TerminalPresentation { screen.authoritative = true; screen.sequence++; screen.turnPending = false; + screen.inputPending = false; + screen.responseAvailable = true; } clearAnswer(id: string): void { const screen = this.screens.get(id); @@ -187,6 +206,13 @@ export class TerminalPresentation { screen.turnPending = true; screen.busySeen = false; } + /** A confirmed text/voice submission acknowledges a response; raw terminal keys do not. */ + submitted(id:string):void { + this.pending(id); + this.screen(id).inputPending = true; + } + /** Successful device input resolves the prior response notice until fresh progress or an answer. */ + hasPendingInput(id:string):boolean { return this.screens.get(id)?.inputPending ?? false; } /** * What leaves for the companion device is masked like any text handed out: the whole screen at once, so a key * the terminal wrapped over two lines is still found (the registry tolerates the line break), before it is cut. @@ -220,8 +246,9 @@ export class TerminalPresentation { async read(id: string) { const session = this.port.listMetadata().find((s) => s.id === id); if (!session) throw new Error("Session unavailable"); - const screen = this.screen(id), - viewport = await this.text(id), + const screen = this.screen(id); + this.observeStatus(screen,session); + const viewport = await this.text(id), menu = session.provider === "codex" ? codexMenu(viewport) : null; if (screen.answerExpiresAt !== null && screen.answerExpiresAt <= Date.now()) { screen.lastAnswer = ""; @@ -267,8 +294,13 @@ export class TerminalPresentation { (session.provider !== "codex" || !screen.lastAnswer || (screen.turnPending && screen.busySeen)) - ) + ) { + if (reply !== screen.lastAnswer) { + screen.inputPending = false; + if(session.status==="idle" && !session.turnCompleted)screen.responseAvailable=true; + } screen.lastAnswer = reply; + } } const body = session.status === "needs_approval" diff --git a/src/main/services/configuredModel.ts b/src/main/services/configuredModel.ts new file mode 100644 index 00000000..3cc84d75 --- /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 5d648c29..8e0d2b13 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -1,14 +1,17 @@ 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"; +import { isPluginDataPath, worktreeGitAccess } from "./worktreeGitAccess.ts"; 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. */ @@ -20,6 +23,133 @@ export const BUBBLEWRAP_USERNS_DOCS = "docs/installing-and-security.md#linux-whe /** A failed bubblewrap check is repeated after this long, so allowing it takes effect without a restart. */ 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."; +export const WORKTREE_GIT_NOTE = "A plugin worktree's Git metadata stays read-only under agent isolation to protect the shared repository and sibling worktrees. You can edit project files; git add and git commit are unavailable here. The host environment collects your file edits into a review for the user to accept."; + +/** 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). */ @@ -48,6 +178,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; @@ -70,11 +202,20 @@ export interface IsolationLaunch { sessionId: string; provider: ProviderId; cwd: string; + /** Original project root for validating a linked worktree. */ + taskProjectRoot?: string; command: string; args: readonly string[]; 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 +224,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 +243,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 +264,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 +321,89 @@ 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 requestedCwdInPluginData = isPluginDataPath(launch.cwd, this.options.userDataPath); 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 trustedWorktree = worktreeGitAccess(cwd, launch.taskProjectRoot ?? cwd, this.options.userDataPath); + if ((requestedCwdInPluginData || isPluginDataPath(cwd, this.options.userDataPath)) && !trustedWorktree) { + throw new LaunchRefusal("The plugin environment worktree could not be verified against the task's original Git repository. The agent was not started."); + } + 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({ + ...(trustedWorktree ? { worktreeGitAccess: trustedWorktree } : {}), 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 worktreeGitMetadataReadOnly = Boolean(trustedWorktree); + const isolationNote = worktreeGitMetadataReadOnly ? `${ISOLATION_NOTE} ${WORKTREE_GIT_NOTE}` : ISOLATION_NOTE; + const env: Record = { ...launchEnvironment, TMPDIR: `${temp}/`, TMP: temp, TEMP: temp, GIT_TEMPLATE_DIR: gitTemplate, [ISOLATION_ENV]: isolationNote }; + if (worktreeGitMetadataReadOnly) env.GIT_OPTIONAL_LOCKS = "0"; 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, + ...(worktreeGitMetadataReadOnly ? { isolationReason: WORKTREE_GIT_NOTE } : {}), 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,13 +411,19 @@ 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 { command: this.bubblewrap!, args, env, + ...(worktreeGitMetadataReadOnly ? { isolationReason: WORKTREE_GIT_NOTE } : {}), cleanup: () => { cleanup(); // bwrap created an empty `.git/hooks` as the throwaway hooks mount point in a folder that had no repository: @@ -233,13 +439,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 +491,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 bd0c47b2..f3d6cc93 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(); @@ -39,8 +39,10 @@ export function bubblewrapArguments( seen.add(path); args.push("--bind", path, path); } + // Linux cannot permit atomic ref updates to one branch without making the containing directory writable (which + // also permits new sibling refs). Linked-worktree Git metadata therefore stays read-only under bubblewrap. for (const path of paths.writableFiles) { - if (exists(path) === "file") args.push("--bind", path, path); + if (exists(path) === "file" && ![...seen].some((folder) => within(path, folder))) args.push("--bind", path, path); } for (const path of paths.unreadable) { const kind = exists(path); @@ -87,6 +89,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 5cdcb9a5..9d3368e0 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -1,9 +1,10 @@ -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"; import { otherSpellings } from "../onDiskPath.ts"; import { openCodeConfigPaths } from "../inspectedConfig.ts"; +import type { WorktreeGitAccess } from "./worktreeGitAccess.ts"; /** * The folders one isolated agent may write, the ones it may not read, and the sockets it may connect to. Pure: the @@ -30,9 +31,20 @@ 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[]; + /** Host-derived from cwd + task root after validating a linked worktree; never supplied by a plugin. */ + worktreeGitAccess?: WorktreeGitAccess | null; } 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 +156,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", "checkpoints.json", "checkpoint-objects", "task-budgets.json", "usage-prices.json", "flow-approvals.json", "session-timeline"] .map((name) => join(userDataPath, name)); } @@ -212,8 +224,38 @@ 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))]; - const project = input.cwd; + 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 access=input.worktreeGitAccess; + const worktree=access && isWithin(input.cwd,access.cwd) ? access : null; + const project = worktree?.cwd ?? 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 +282,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 +327,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,24 +335,41 @@ 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. ...(existsSync(join(project, ".git", "config")) ? [join(project, ".git", "config"), join(project, ".git", "config.lock")] : []), + ...(worktree ? [ + join(project, ".git"), + join(worktree.adminDir, "HEAD") + ] : []), ...(input.provider === "codex" ? ownFolders.map((folder) => join(folder, "config.toml")) : []), ...(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")])), - gitHooks: all([join(project, ".git", "hooks")]), + protectedDirectories: all([ + ...configSources.agentDirectories.flatMap((folder) => [join(folder, "agent"), join(folder, "agents")]), + ...(worktree ? [worktree.adminDir, worktree.commonDir] : []) + ]), + gitHooks: all([join(project, ".git", "hooks"), ...(worktree ? [join(worktree.adminDir, "hooks"), join(worktree.commonDir, "hooks")] : [])]), projectRoots: input.readOnlyProject ? [] : all([project]), - unreadable: all([...sensitive, ...others, ...privateData]), - readableAgain: all([...grants, ...movedHomes]), + unreadable: all([ + ...sensitive, ...others, ...privateData, ...(worktree ? worktree.otherAdminDirs : []), + ...(input.restrictHomeReads ? [home, hostHome] : []), + ...(input.deniedReadPaths ?? []), + ]), + readableAgain: all([...grants, ...movedHomes, ...(worktree ? [project, worktree.adminDir] : []), + ...(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 00000000..ac9ad58f --- /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 1342435f..18112f5d 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,30 @@ 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("")})`); + } + } + // plugin-data is denied as a whole to hide the private parent and every sibling worktree. Reopen this one exact + // validated linked-worktree project for writes after that parent denial. The Git metadata denies below still win. + const privateRootProjects = paths.projectRoots.filter((project) => paths.readableAgain.includes(project) + && paths.unreadable.some((hidden) => hidden.endsWith("/plugin-data") && project.startsWith(`${hidden}/`))); + if (privateRootProjects.length > 0) { + lines.push(`(allow file-write*${privateRootProjects.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/isolation/worktreeGitAccess.ts b/src/main/services/isolation/worktreeGitAccess.ts new file mode 100644 index 00000000..737cb0d5 --- /dev/null +++ b/src/main/services/isolation/worktreeGitAccess.ts @@ -0,0 +1,259 @@ +import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +const POINTER_LIMIT = 4096; + +/** Host-validated shared Git metadata for one linked worktree in the host's plugin-data tree. */ +export interface WorktreeGitAccess { + cwd: string; + commonDir: string; + adminDir: string; + otherAdminDirs: string[]; +} + +/** + * Validates the registered worktree boundary using only the host's resolved launch cwd, original task root, and + * userData. The returned paths are used to keep shared Git metadata read-only; no Git path or grant comes from a + * plugin. Any malformed, detached, shared-branch, or mismatched worktree is refused by returning null. + */ +export function worktreeGitAccess(cwd: string, taskRoot: string, userDataPath: string): WorktreeGitAccess | null { + try { + const launchCwd = real(cwd); + const userData = real(userDataPath); + const pluginData = resolve(userData, "plugin-data"); + if (!within(launchCwd, pluginData) || launchCwd === pluginData) return null; + // A provider may start in a project subfolder. Its Git boundary is the enclosing registered worktree, + // which is still validated below against the host task's shared repository and administration pointers. + const workingTree = nearestGitLayout(launchCwd)?.worktree; + if (!workingTree || !within(workingTree, pluginData) || workingTree === pluginData) return null; + + const task = real(taskRoot); + const taskLayout = nearestGitLayout(task); + if (!taskLayout || !within(task, taskLayout.worktree)) return null; + if (within(taskLayout.worktree, pluginData) && !isRegisteredLinkedWorktree(taskLayout)) return null; + if (workingTree === taskLayout.worktree) return null; + + const gitEntry = join(workingTree, ".git"); + const gitEntryStat = lstatSync(gitEntry); + if (!gitEntryStat.isFile()) return null; + const gitDirText = pointer(gitEntry, /^gitdir:\s*(.+?)\s*$/mu); + if (!gitDirText) return null; + const adminDir = real(isAbsolute(gitDirText) ? gitDirText : resolve(workingTree, gitDirText)); + const commonDir = real(readCommonDir(adminDir)); + if (commonDir !== taskLayout.commonDir) return null; + // The deny/reopen ordering deliberately hides plugin-data as a parent and reopens only the worker tree. If shared + // Git storage overlaps either path tree, that boundary cannot be expressed without broadening the reopen. + if (overlaps(commonDir, workingTree) || overlaps(commonDir, pluginData)) return null; + + const worktreesPath = join(commonDir, "worktrees"); + const worktreesDir = real(worktreesPath); + if (worktreesDir !== worktreesPath) return null; + if (dirname(adminDir) !== worktreesDir) return null; + if (!directory(adminDir)) return null; + + const adminGitdir = pointer(join(adminDir, "gitdir"), /^(.+?)\s*$/mu); + if (!adminGitdir) return null; + const registeredEntry = real(isAbsolute(adminGitdir) ? adminGitdir : resolve(adminDir, adminGitdir)); + if (registeredEntry !== gitEntry) return null; + const adminCommon = pointer(join(adminDir, "commondir"), /^(.+?)\s*$/mu); + if (!adminCommon) return null; + if (real(isAbsolute(adminCommon) ? adminCommon : resolve(adminDir, adminCommon)) !== commonDir) return null; + + const head = readRegular(join(adminDir, "HEAD")); + const branch = head === null ? null : /^ref:\s*(refs\/heads\/.+)\s*$/mu.exec(head)?.[1] ?? null; + if (!branch || !validBranchRef(branch)) return null; + if (!hasBranchRef(commonDir, branch)) return null; + const otherAdminDirs = otherAdministrations(worktreesDir, adminDir); + // The primary checkout is not listed under .git/worktrees. A forced linked worktree can share its branch, so + // include the host-validated task root's own Git directory HEAD in the single-writer check. + if (!otherAdminDirs || anotherWorktreeOwns([...otherAdminDirs, taskLayout.gitDir, commonDir], branch)) return null; + return { cwd: workingTree, commonDir, adminDir, otherAdminDirs }; + } catch { + return null; + } +} + +/** Does the launch cwd sit in the private plugin-data tree where only a verified project may be reopened? */ +export function isPluginDataPath(path: string, userDataPath: string): boolean { + try { + const lexical = resolve(path); + const lexicalRoot = resolve(userDataPath, "plugin-data"); + const realRoot = resolve(real(userDataPath), "plugin-data"); + return within(lexical, lexicalRoot) || within(lexical, realRoot) || within(real(path), realRoot); + } catch { + return false; + } +} + +interface GitLayout { worktree: string; gitDir: string; commonDir: string } + +function nearestGitLayout(path: string): GitLayout | null { + for (let current = path; ; current = dirname(current)) { + const layout = gitLayout(current); + if (layout) return layout; + const parent = dirname(current); + if (parent === current) return null; + } +} + +function gitLayout(worktree: string): GitLayout | null { + try { + const entry = join(worktree, ".git"); + const info = lstatSync(entry); + let gitDir: string; + if (info.isDirectory()) gitDir = real(entry); + else if (info.isFile()) { + const value = pointer(entry, /^gitdir:\s*(.+?)\s*$/mu); + if (!value) return null; + gitDir = real(isAbsolute(value) ? value : resolve(worktree, value)); + } else return null; + if (!directory(gitDir)) return null; + return { worktree, gitDir, commonDir: real(readCommonDir(gitDir)) }; + } catch { + return null; + } +} + +/** A task root can itself be a worker worktree; accept it only when its host-side Git pointers agree exactly. */ +function isRegisteredLinkedWorktree(layout: GitLayout): boolean { + try { + const entry = join(layout.worktree, ".git"); + const entryInfo = lstatSync(entry); + if (!entryInfo.isFile() || entryInfo.isSymbolicLink()) return false; + const gitDirText = pointer(entry, /^gitdir:\s*(.+?)\s*$/mu); + if (!gitDirText) return false; + const adminDir = real(isAbsolute(gitDirText) ? gitDirText : resolve(layout.worktree, gitDirText)); + const worktreesDir = join(layout.commonDir, "worktrees"); + if (real(worktreesDir) !== worktreesDir || dirname(adminDir) !== worktreesDir || adminDir !== layout.gitDir || !directory(adminDir)) return false; + const adminGitdir = pointer(join(adminDir, "gitdir"), /^(.+?)\s*$/mu); + if (!adminGitdir || real(isAbsolute(adminGitdir) ? adminGitdir : resolve(adminDir, adminGitdir)) !== entry) return false; + const adminCommon = pointer(join(adminDir, "commondir"), /^(.+?)\s*$/mu); + return Boolean(adminCommon && real(isAbsolute(adminCommon) ? adminCommon : resolve(adminDir, adminCommon)) === layout.commonDir); + } catch { + return false; + } +} + +function readCommonDir(gitDir: string): string { + const path = join(gitDir, "commondir"); + if (!existsSync(path)) return gitDir; + const value = pointer(path, /^(.+?)\s*$/mu); + if (!value) throw new Error("Invalid Git common directory pointer."); + return isAbsolute(value) ? value : resolve(gitDir, value); +} + +function otherAdministrations(worktreesDir: string, ownAdminDir: string): string[] | null { + const found: string[] = []; + for (const entry of readdirEntries(worktreesDir)) { + const admin = join(worktreesDir, entry); + const info = lstatSync(admin); + if (info.isSymbolicLink()) return null; + if (!info.isDirectory() || real(admin) !== admin) return null; + if (admin === ownAdminDir) continue; + found.push(admin); + } + return found; +} + +function anotherWorktreeOwns(otherAdmins: readonly string[], branch: string): boolean { + for (const admin of otherAdmins) { + const head = readRegular(join(admin, "HEAD")); + if (head !== null && /^ref:\s*(refs\/heads\/.+)\s*$/mu.exec(head)?.[1] === branch) return true; + } + return false; +} + +/** Checks an optional path below an expected real directory without following symlinked components. */ +function hasBranchRef(commonDir: string, branch: string): boolean { + const looseRef = join(commonDir, ...branch.split("/")); + const looseStatus = optionalRegularStatus(looseRef, commonDir); + if (looseStatus === null) return false; + if (looseStatus === "present") { + const oid = readRegular(looseRef); + return oid !== null && /^(?:[0-9a-f]{40}|[0-9a-f]{64})\s*$/iu.test(oid); + } + // pack-refs removes loose refs and may be used by a valid linked worktree. It is read only here; the complete + // common Git directory is protected by the OS layer, so this never opens a write grant. + const packedRefs = join(commonDir, "packed-refs"); + try { + const info = lstatSync(packedRefs); + if (!info.isFile() || info.isSymbolicLink() || info.size > 64 * 1024 * 1024) return false; + const lines = readFileSync(packedRefs, "utf8").split(/\r?\n/u); + const matches = lines.filter((line) => { + if (!line || line.startsWith("#") || line.startsWith("^")) return false; + const space = line.indexOf(" "); + return space > 0 && line.slice(space + 1) === branch; + }); + return matches.length === 1 && /^(?:[0-9a-f]{40}|[0-9a-f]{64})\s/iu.test(matches[0]); + } catch { + return false; + } +} + +/** Checks a loose-ref path without following symlinked components; absent refs are resolved through packed-refs. */ +function optionalRegularStatus(path: string, root: string): "present" | "missing" | null { + const rest = relative(root, path); + if (rest.startsWith("..") || isAbsolute(rest)) return null; + let current = root; + try { + const rootInfo = lstatSync(root); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink() || real(root) !== root) return null; + } catch { return null; } + const parts = rest.split(/[\\/]/u).filter(Boolean); + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + current = join(current, part); + try { + const info = lstatSync(current); + if (info.isSymbolicLink() || real(current) !== current) return null; + if (index === parts.length - 1) return info.isFile() ? "present" : null; + if (!info.isDirectory()) return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + return null; + } + } + return null; +} + +function readdirEntries(path: string): string[] { + return readdirSync(path); +} + +function pointer(path: string, pattern: RegExp): string | null { + const content = readRegular(path); + const match = content === null ? null : pattern.exec(content); + return match?.[1]?.trim() || null; +} + +function readRegular(path: string): string | null { + try { + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > POINTER_LIMIT) return null; + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +function directory(path: string): boolean { + try { return lstatSync(path).isDirectory(); } catch { return false; } +} + +function real(path: string): string { + return realpathSync.native(path); +} + +function validBranchRef(ref: string): boolean { + if (!/^refs\/heads\/[A-Za-z0-9._/-]+$/u.test(ref) || ref.includes("//") || ref.includes("..") || ref.includes("@{")) return false; + return ref.split("/").every((part) => part.length > 0 && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(".lock")); +} + +function within(path: string, root: string): boolean { + const rest = relative(root, path); + return rest === "" || (!rest.startsWith("..") && !isAbsolute(rest)); +} + +function overlaps(left: string, right: string): boolean { + return within(left, right) || within(right, left); +} diff --git a/src/main/services/providerModels.ts b/src/main/services/providerModels.ts index 48808b7e..5b911a00 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/main/services/safety/SecretRedaction.ts b/src/main/services/safety/SecretRedaction.ts index 7a0df560..858d21b6 100644 --- a/src/main/services/safety/SecretRedaction.ts +++ b/src/main/services/safety/SecretRedaction.ts @@ -20,11 +20,11 @@ * and are never logged, sent or shown. */ -const SECRET_MARKER = ''; /** Shorter values are not keys, and removing them would only garble ordinary text. */ const MIN_SECRET_CHARS = 8; /** Long enough for a PEM key or a service-account JSON; the search costs the same for any length. */ const MAX_SECRET_CHARS = 65_536; +const MAX_NATIVE_INDEXOF_PATTERN_CHARS = 128; const MAX_VALUES_PER_OWNER = 64; const MAX_OWNERS = 512; /** What wrapping may put between two characters of a key: whitespace and line breaks, box-drawing sides. */ @@ -39,6 +39,9 @@ const TAIL_MARGIN_CHARS = 16_384; * safeWindowStart); where there is none, it masks the whole text. */ const TAIL_SEARCH_CHARS = 65_536; +/** How far back forcedBoundary looks for a cut, and how far around it it checks that no match crosses. */ +const FORCED_SEARCH_CHARS = 65_536; +const FORCED_REACH_CHARS = 262_144; /** The longest value JSON_SECRET_VALUE takes, the one rule whose value may run over a line break. */ const JSON_VALUE_MAX_CHARS = 2_048; const PRIVATE_KEY_HEADER = /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/gu; @@ -68,6 +71,12 @@ export class SecretRedactionRegistry { /** The longest text one held value can match: its characters plus a full wrap gap between each two. */ private knownSpan = 0; private dirty = false; + private workerRevision=0; + + /** Host-private mirror for the trusted search worker. Never send this snapshot to a renderer or plugin. */ + snapshotForWorker():{revision:number;values:string[]}{ + return {revision:this.workerRevision,values:[...new Set([...this.owners.values()].flatMap(values=>[...values]))]}; + } /** * Adds values under an owner (`vault`, `session:`, `plugin:`); short or oversized values are ignored. @@ -98,19 +107,86 @@ export class SecretRedactionRegistry { } set.add(trimmed); this.dirty = true; + this.workerRevision++; } } /** Forgets an owner's values (a card closed, a plugin stopped). */ clear(owner: string): void { - if (this.owners.delete(owner)) this.dirty = true; + if (this.owners.delete(owner)){this.dirty = true;this.workerRevision++;} } redact(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; - let result = maskKnownValues(text, this.knownForms()); - result = result.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${SECRET_MARKER}${closing}`); - return redactCredentials(result); + const forms = this.knownForms(); + // With no held values there is no structural text for a known match to erase, so reuse the direct generic + // pipeline instead of allocating and merging empty-registry source-range streams on ordinary output. + if (forms.bare.length === 0 && forms.exact.length === 0) return redactJsonAndCredentials(text); + return redactKnownAndCredentials(text, forms); + } + + /** + * Masks a prefix that can be separated from any later text without changing whole-text redaction. The boundary + * is a line start that `safeWindowStart` proves no current rule crosses, with enough lookahead to rule out a + * held value that might only finish in a later append. Callers retain and rescan the returned raw suffix. + * + * A line without a safe boundary, an open PEM block, or an unusually long held value can leave a large suffix + * pending. That is intentional: callers must use whole-suffix redaction in that worst case rather than masking + * chunks independently and risking a partial secret. + */ + redactStablePrefix(text: string): { sourceLength: number; maskedText: string } { + if (typeof text !== 'string' || text.length === 0) return { sourceLength: 0, maskedText: '' }; + const forms = this.knownForms(); + // Known-value masking can remove a PEM footer before the generic PEM pass. A boundary inferred from the raw + // footer would then separate text that the complete masking pass treats as one open private-key block. + if (forms.armour && PRIVATE_KEY_HEADER_TEXT.test(text)) return { sourceLength: 0, maskedText: '' }; + if (text.length <= this.knownSpan) return { sourceLength: 0, maskedText: '' }; + const desired = Math.min(text.length - this.knownSpan, openPrivateKeyStart(text) ?? text.length); + const boundary = safeWindowStart(text, desired, forms, this.knownSpan); + if (boundary === null || boundary <= 0) return { sourceLength: 0, maskedText: '' }; + return { sourceLength: boundary, maskedText: this.redact(text.slice(0, boundary)) }; + } + + /** How much text after a cut redactStablePrefix and forcedBoundary need to judge it: window sizing for callers. */ + lookahead(): number { + this.knownForms(); + return Math.max(this.knownSpan, FORCED_REACH_CHARS) + 4_096; + } + + /** + * For text with no safe line start (one line of megabytes): a cut at or before `desired` that leaves at least + * the checked reach after it, at most + * FORCED_SEARCH_CHARS back, after whitespace or between two non-ASCII characters, outside every private-key block, + * and crossed by no match of a held value, a JSON value, a rule (its whole match, labels included) or a random run + * within FORCED_REACH_CHARS of it. Masking the text before the cut and the text from it on separately then masks + * everything whole-text masking does there; only a single match longer than the reach could still be split. Null + * when there is no such cut. + */ + forcedBoundary(text: string, desired: number): number | null { + if (typeof text !== 'string' || desired <= 0) return null; + const forms = this.knownForms(); + if (forms.armour && PRIVATE_KEY_HEADER_TEXT.test(text)) return null; + const open = openPrivateKeyStart(text); + const reach = Math.max(FORCED_REACH_CHARS, this.knownSpan + 4_096); + // Text after the cut must be long enough to show any match that would cross it. + const limit = Math.min(desired, text.length - reach, open ?? Number.MAX_SAFE_INTEGER); + const floor = Math.max(1, limit - FORCED_SEARCH_CHARS); + const blocks = privateKeyBlocks(text, limit + 1); + let spans: Array<[number, number]> = []; + let windowStart = -1; + for (let cut = limit; cut >= floor; cut--) { + const before = text.charCodeAt(cut - 1); + if (!(WHITESPACE.test(text[cut - 1]!) || (before > 0x7e && text.charCodeAt(cut) > 0x7e))) continue; + if (blocks.some(([from, to]) => from < cut && cut < to)) continue; + // The checked window keeps `reach` characters on both sides of every cut it judges. + if (windowStart < 0 || (windowStart > 0 && cut - reach < windowStart)) { + windowStart = Math.max(0, cut - 2 * reach); + spans = matchSpans(text.slice(windowStart, Math.min(text.length, cut + reach)), forms, windowStart); + } + if (spans.some(([from, to]) => from < cut && cut < to)) continue; + return cut; + } + return null; } /** @@ -166,6 +242,13 @@ export class SecretRedactionRegistry { } } +/** Reuses every existing masking rule in the trusted worker, preserving the registry's per-owner capacity. */ +export function redactionRegistryFromWorkerSnapshot(values:readonly string[]):SecretRedactionRegistry{ + const registry=new SecretRedactionRegistry(); + for(let i=0;i= 0x09 && code <= 0x0d); @@ -175,92 +258,233 @@ function isWrapCharacter(code: number): boolean { } function withoutWrapCharacters(text: string): string { - let result = ""; - let from = 0; - for (let i = 0; i < text.length; i++) { - if (!isWrapCharacter(text.charCodeAt(i))) continue; - result += text.slice(from, i); - from = i + 1; + const pattern = /[\s\u2500-\u257f]/u; + if (!pattern.test(text)) return text; + // Global replacement creates a string fragment for every gap. Build flat, bounded pieces instead of + // retaining millions of newline fragments in the search worker's heap. + const buffer = new Uint16Array(16_384); + const parts: string[] = []; + for (let start = 0; start < text.length; start += buffer.length) { + const chunk = text.slice(start, start + buffer.length); + if (!pattern.test(chunk)) { + parts.push(chunk); + continue; + } + let length = 0; + for (let at = 0; at < chunk.length; at++) { + const code = chunk.charCodeAt(at); + if (!isWrapCharacter(code)) buffer[length++] = code; + } + if (length > 0) parts.push(String.fromCharCode(...buffer.subarray(0, length))); } - return from === 0 ? text : result + text.slice(from); + return parts.join(""); } -/** - * Replaces every held value in `text`: the wrap-free forms also where wrapping put up to MAX_WRAP_GAP wrap - * characters between two of their characters, the exact forms as written. Matches are taken leftmost first, the - * longest at a position, and never overlap. - */ -function maskKnownValues(text: string, forms: KnownForms): string { - const endAt = knownMatchEnds(text, forms); - if (!endAt) return text; - let result = ""; - let kept = 0; - for (let at = 0; at < text.length;) { - const end = endAt[at]; - if (end === 0) { at++; continue; } - result += text.slice(kept, at) + SECRET_MARKER; - kept = end; - at = end; +/** All held-value matches in start order, keeping only the longest valid match at each start. */ +type KnownMatch = { start: number; end: number; kind?: string; priority?: number }; +type MatchStream = { iterator: Generator; current: KnownMatch }; + +function* knownMatches(text: string, forms: KnownForms): Generator { + if (forms.bare.length === 0 && forms.exact.length === 0) return; + const bareText = forms.bare.length ? withoutWrapCharacters(text) : ''; + const hasWrapCharacters = forms.bare.length > 0 && bareText !== text; + + const streams = forms.exact.map(form => candidateOccurrences(text, form)); + if (hasWrapCharacters) streams.push(wrappedMatches(text, bareText, forms.bare)); + else for (const form of forms.bare) streams.push(candidateOccurrences(text, form)); + yield* mergeMatches(streams); +} + +/** Merge already ordered hit streams by source start, choosing the longest at each start. */ +function* mergeMatches(streams: Generator[]): Generator { + const occurrences = orderedOccurrences(streams); + let next = occurrences.next(); + while (!next.done) { + const start = next.value.start; + let end = next.value.end; + next = occurrences.next(); + while (!next.done && next.value.start === start) { + end = Math.max(end, next.value.end); + next = occurrences.next(); + } + yield { start, end }; } - return kept === 0 ? text : result + text.slice(kept); } -/** - * For each position of `text`, the end of the longest held-value match that starts there (0: none), or null when - * nothing matches. The wrap-free forms are searched in the text with its wrap characters taken out (a map leads - * back to the original positions), the exact forms in the text itself; each with Knuth-Morris-Pratt, linear in - * the text plus the form, whatever either holds. - */ -function knownMatchEnds(text: string, forms: KnownForms): Int32Array | null { - if (forms.bare.length === 0 && forms.exact.length === 0) return null; - const exact = forms.exact.filter(form => text.includes(form)); - const bare = forms.bare.length ? withoutWrapCharacters(text) : ''; - const present = forms.bare.filter(form => bare.includes(form)); - if (present.length === 0 && exact.length === 0) return null; - const endAt = new Int32Array(text.length); - for (const form of exact) { - for (const start of occurrences(text, form)) endAt[start] = Math.max(endAt[start], start + form.length); - } - if (present.length === 0) return endAt; - // Where each character of `bare` stands in `text`, and how many oversized gaps lie before it (a match may - // not cross one). - const positions = new Int32Array(bare.length); - const oversized = new Int32Array(bare.length + 1); - for (let i = 0, count = 0, previous = -1; i < text.length; i++) { - if (isWrapCharacter(text.charCodeAt(i))) continue; - oversized[count + 1] = oversized[count] + (previous >= 0 && i - previous - 1 > MAX_WRAP_GAP ? 1 : 0); - positions[count++] = i; - previous = i; - } - for (const form of present) { - for (const start of occurrences(bare, form)) { - // Only the gaps inside the match count, not the one before its first character. - if (oversized[start + form.length] - oversized[start + 1] !== 0) continue; - const from = positions[start]; - endAt[from] = Math.max(endAt[from], positions[start + form.length - 1] + 1); +/** Merge hit streams into start order without discarding same-start candidates. */ +function* orderedOccurrences(streams: Generator[]): Generator { + if (streams.length === 1) { + yield* streams[0]!; + return; + } + const heap: MatchStream[] = []; + const push = (stream: MatchStream): void => { + let index = heap.length; + heap.push(stream); + while (index > 0) { + const parent = (index - 1) >> 1; + if (heap[parent]!.current.start <= stream.current.start) break; + heap[index] = heap[parent]!; + index = parent; + } + heap[index] = stream; + }; + const pop = (): MatchStream => { + const first = heap[0]!; + const last = heap.pop()!; + if (heap.length > 0) { + let index = 0; + while (true) { + const left = index * 2 + 1; + if (left >= heap.length) break; + const right = left + 1; + const child = right < heap.length && heap[right]!.current.start < heap[left]!.current.start ? right : left; + if (heap[child]!.current.start >= last.current.start) break; + heap[index] = heap[child]!; + index = child; + } + heap[index] = last; + } + return first; + }; + const enqueueFirst = (iterator: Generator): void => { + const next = iterator.next(); + if (!next.done) push({ iterator, current: next.value }); + }; + const advance = (stream: MatchStream): void => { + const next = stream.iterator.next(); + if (!next.done) { + stream.current = next.value; + push(stream); + } + }; + + for (const iterator of streams) enqueueFirst(iterator); + while (heap.length > 0) { + const first = pop(); + const current = first.current; + advance(first); + yield current; + } +} + +function* candidateOccurrences(text: string, pattern: string): Generator { + if (pattern.length > MAX_NATIVE_INDEXOF_PATTERN_CHARS) { + yield* kmpOccurrences(text, pattern); + return; + } + yield* exactOccurrences(text, pattern); +} + +function* exactOccurrences(text: string, pattern: string): Generator { + for (let from = 0; from <= text.length - pattern.length;) { + const start = text.indexOf(pattern, from); + if (start < 0) return; + yield { start, end: start + pattern.length }; + // Advancing by one preserves overlapping hits for the leftmost/longest merger. + from = start + 1; + } +} + +/** Keep worst-case scan cost linear for long patterns whose repetitive near-matches fool native indexOf. */ +function* kmpOccurrences(text: string, pattern: string): Generator { + const firstCode = pattern.charCodeAt(0); + const patternLength = pattern.length; + const searchPrefix = pattern.slice(0, MAX_NATIVE_INDEXOF_PATTERN_CHARS); + const prefixLength = searchPrefix.length; + const firstStart = text.indexOf(searchPrefix); + if (firstStart < 0) return; + + const failure = kmpFailure(pattern); + for (let i = firstStart + prefixLength - 1, k = prefixLength - 1; i < text.length; i++) { + // Skip ordinary spans in native code when no KMP prefix is active. The bounded prefix stays code-unit based. + if (k === 0) { + const start = text.indexOf(searchPrefix, i); + if (start < 0) return; + i = start + prefixLength - 1; + k = prefixLength - 1; + } + const textCode = text.charCodeAt(i); + let patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + while (k > 0 && textCode !== patternCode) { + k = failure[k - 1]!; + patternCode = k === 0 ? firstCode : pattern.charCodeAt(k); + } + if (textCode === patternCode) k++; + if (k === patternLength) { + yield { start: i - k + 1, end: i + 1 }; + k = failure[k - 1]!; } } - return endAt; } -/** Every start of `pattern` in `text`, overlapping ones included (Knuth-Morris-Pratt). */ -function* occurrences(text: string, pattern: string): Generator { +/** Prefix lengths for Knuth-Morris-Pratt. Long candidate patterns use this to retain linear scan cost. */ +function kmpFailure(pattern: string): Int32Array { const failure = new Int32Array(pattern.length); for (let i = 1, k = 0; i < pattern.length; i++) { - while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; + while (k > 0 && pattern.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]!; if (pattern.charCodeAt(i) === pattern.charCodeAt(k)) k++; failure[i] = k; } - for (let i = 0, k = 0; i < text.length; i++) { - while (k > 0 && text.charCodeAt(i) !== pattern.charCodeAt(k)) k = failure[k - 1]; - if (text.charCodeAt(i) === pattern.charCodeAt(k)) k++; - if (k === pattern.length) { - yield i - k + 1; - k = failure[k - 1]; + return failure; +} + +/** + * Find wrap-tolerant matches from one shared compact-text merge and one source-offset ring. All bare starts are + * processed monotonically; a ring as long as the longest form plus one retains every start through the farthest + * end considered at that start, including overlapping candidates at later starts. + */ +function* wrappedMatches(text: string, bareText: string, patterns: readonly string[]): Generator { + const occurrences = orderedOccurrences(patterns.map(pattern => candidateOccurrences(bareText, pattern))); + let next = occurrences.next(); + if (next.done) return; + + const longestPattern = patterns[0]!.length; // knownForms sorts patterns longest first + const ringLength = longestPattern + 1; + const positions = new Uint32Array(ringLength); + const oversizedAfter = new Uint32Array(ringLength); + let raw = 0; + let bare = 0; + let previous = -1; + let oversizedGaps = 0; + + const translate = (start: number, end: number): KnownMatch | null => { + while (bare < end) { + const code = text.charCodeAt(raw); + if (!isWrapCharacter(code)) { + if (previous >= 0 && raw - previous - 1 > MAX_WRAP_GAP) oversizedGaps++; + const slot = bare % ringLength; + positions[slot] = raw; + oversizedAfter[slot] = oversizedGaps; + bare++; + previous = raw; + } + raw++; } + const startSlot = start % ringLength; + const endSlot = (end - 1) % ringLength; + // Exclude the gap before the first matched character; only interior gaps can invalidate this candidate. + if (oversizedAfter[endSlot]! - oversizedAfter[startSlot]! > 0) return null; + return { start: positions[startSlot]!, end: positions[endSlot]! + 1 }; + }; + + while (!next.done) { + const compactStart = next.value.start; + let rawStart = -1; + let rawEnd = -1; + while (!next.done && next.value.start === compactStart) { + // Check every candidate before selecting the longest. A longer form can cross an oversized gap while a + // shorter form at the same start remains valid. + const translated = translate(next.value.start, next.value.end); + if (translated) { + rawStart = translated.start; + rawEnd = Math.max(rawEnd, translated.end); + } + next = occurrences.next(); + } + if (rawEnd >= 0) yield { start: rawStart, end: rawEnd }; } } - function lastChars(text: string, maxChars: number): string { return text.length <= maxChars ? text : text.slice(text.length - maxChars); } @@ -288,11 +512,11 @@ function safeWindowStart(text: string, desired: number, forms: KnownForms, known const floor = Math.max(0, desired - TAIL_SEARCH_CHARS); const blocks = privateKeyBlocks(text, desired + 1); const seen = new Map(); - let b = text.lastIndexOf('\n', desired - 1) + 1; + let b = lineStartBefore(text, desired - 1, floor); while (b > floor) { const block = blocks.find(([from, to]) => from < b && b < to); if (block) { - b = text.lastIndexOf('\n', block[0] - 1) + 1; + b = lineStartBefore(text, block[0] - 1, floor); continue; } // The last character before `b` that is not whitespace; every line start in the whitespace before it @@ -300,13 +524,28 @@ function safeWindowStart(text: string, desired: number, forms: KnownForms, known let last = b - 1; while (last >= 0 && WHITESPACE.test(text[last]!)) last--; if (isLineStartUncrossed(text, b, last, forms, knownSpan, seen)) return b; - b = last < 0 ? 0 : text.lastIndexOf('\n', Math.min(last, b - 2)) + 1; + b = last < 0 ? 0 : lineStartBefore(text, Math.min(last, b - 2), floor); } return null; } +/** + * The start of the line holding `from`, searching no further back than `floor` (which it returns when there is + * none). A line ends at `\n`, and also at a lone `\r`: a terminal that redraws one row (progress bars, spinners) + * writes no `\n` at all, and no rule's match runs over a `\r` that a `\n` would end (every class that crosses a + * line break excludes both or is checked explicitly by isLineStartUncrossed), so the same checks hold there. + */ +function lineStartBefore(text: string, from: number, floor: number): number { + for (let at = Math.min(from, text.length - 1); at >= floor; at--) { + const code = text.charCodeAt(at); + if (code === 0x0a || (code === 0x0d && text.charCodeAt(at + 1) !== 0x0a)) return at + 1; + } + return floor; +} + /** The private-key blocks the PEM rule masks that start before `limit`: from each header to its END or the end. */ function privateKeyBlocks(text: string, limit: number): Array<[number, number]> { + if (!text.includes('-----BEGIN ')) return []; const blocks: Array<[number, number]> = []; PRIVATE_KEY_HEADER.lastIndex = 0; for (;;) { @@ -320,6 +559,20 @@ function privateKeyBlocks(text: string, limit: number): Array<[number, number]> } } +/** The latest PEM header with no footer yet. Its block remains open when another PTY batch arrives. */ +function openPrivateKeyStart(text: string): number | null { + if (!text.includes('-----BEGIN ')) return null; + PRIVATE_KEY_HEADER.lastIndex = 0; + for (;;) { + const header = PRIVATE_KEY_HEADER.exec(text); + if (!header) return null; + PRIVATE_KEY_FOOTER.lastIndex = header.index + header[0].length; + const footer = PRIVATE_KEY_FOOTER.exec(text); + if (!footer) return header.index; + PRIVATE_KEY_HEADER.lastIndex = footer.index + footer[0].length; + } +} + const WHITESPACE = /\s/u; /** Whether the `"` at `quote` follows a `:` (whitespace between); answers are kept per search in `seen`. */ @@ -336,8 +589,44 @@ function opensJsonValue(text: string, quote: number, seen: Map) /** Characters after which a wrapped run or a separator's whitespace may go on over a line break. */ const CONTINUED_AFTER = /[A-Za-z0-9+=_\-:"']/u; +/** Characters a header scheme or token may continue with after a separator's whitespace (bearer, authorization). */ +const TOKEN_CONTINUATION = /[A-Za-z0-9._~+/=-]/u; +const HEADER_SCHEME = /(?:^|[^A-Za-z])(?:bearer|basic|token|bot)$/iu; + +/** + * Whether a line ending in a letter or digit can still be crossed at `b`. A run of twelve or more (a wrapped token, + * a random run) only goes on when the next line, after whitespace and at most a box side, starts with a run of four + * or more that holds a digit, or of eight (a `Bearer` token); a name's separator (`=`, `:`) may come on the next + * line; and an `Authorization` scheme takes any value. Every other next line ends the match at the line break. + */ +function continuesOverLineBreak(text: string, b: number, last: number): boolean { + const code = text.charCodeAt(last); + const alphanumeric = (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a); + if (!alphanumeric) return true; + if (HEADER_SCHEME.test(text.slice(Math.max(0, last - 6), last + 1))) return true; + let next = b; + while (next < text.length && (WHITESPACE.test(text[next]!) || text[next] === '|' || isBoxDrawing(text.charCodeAt(next)))) next++; + if (next >= text.length) return true; // what follows is not known yet + if (text[next] === ':' || text[next] === '=') return true; + // Wrapped tokens and random runs go on only from a run of twelve or more: every token shape is longer than that, + // and shorter runs are never judged random. + let run = 0; + for (let at = last; at >= 0 && run < 12 && isEntropyRunCharacter(text.charCodeAt(at)); at--) run++; + if (run < 12) return false; + let end = next; + let digit = false; + while (end < text.length && end - next < 8 && TOKEN_CONTINUATION.test(text[end]!)) { + const value = text.charCodeAt(end); + if (value >= 0x30 && value <= 0x39) digit = true; + end++; + } + return end - next >= 8 || (end - next >= 4 && digit); +} + +function isBoxDrawing(code: number): boolean { return code >= 0x2500 && code <= 0x257f; } + function isLineStartUncrossed(text: string, b: number, last: number, forms: KnownForms, knownSpan: number, seen: Map): boolean { - if (last >= 0 && (CONTINUED_AFTER.test(text[last]!) || (text[last] === '>' && text[last - 1] === '='))) return false; + if (last >= 0 && ((CONTINUED_AFTER.test(text[last]!) && continuesOverLineBreak(text, b, last)) || (text[last] === '>' && text[last - 1] === '='))) return false; let quote = text.lastIndexOf('"', b - 1); if (quote >= 0 && b - quote <= JSON_VALUE_MAX_CHARS + 2) { if (opensJsonValue(text, quote, seen)) return false; @@ -346,15 +635,34 @@ function isLineStartUncrossed(text: string, b: number, last: number, forms: Know // Held-value matches that start before `b` lie within knownSpan of it. const checkFrom = Math.max(0, Math.min(last, quote)); const from = Math.max(0, checkFrom - knownSpan); - const endAt = knownMatchEnds(text.slice(from, Math.min(text.length, b + knownSpan)), forms); - if (!endAt) return true; - for (let at = 0; from + at < b; at++) { - if (endAt[at] !== 0 && from + endAt[at] > checkFrom) return false; + const window = text.slice(from, Math.min(text.length, b + knownSpan)); + for (const match of knownMatches(window, forms)) { + if (from + match.start >= b) break; + if (from + match.end > checkFrom) return false; } return true; } -type Rule = { kind: string; pattern: RegExp; replace?: (match: string, ...groups: string[]) => string }; +/** Every candidate match in `window` as whole source spans (labels and separators included), shifted by `offset`. */ +function matchSpans(window: string, forms: KnownForms, offset: number): Array<[number, number]> { + const spans: Array<[number, number]> = []; + for (const match of knownMatches(window, forms)) spans.push([offset + match.start, offset + match.end]); + const json = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags); + for (let match = json.exec(window); match; match = json.exec(window)) spans.push([offset + match.index, offset + match.index + match[0].length]); + for (const rule of RULES) { + if (!rule.pattern) continue; + const pattern = new RegExp(rule.pattern.source, rule.pattern.flags.includes('g') ? rule.pattern.flags : `${rule.pattern.flags}g`); + for (let match = pattern.exec(window); match; match = pattern.exec(window)) { + spans.push([offset + match.index, offset + match.index + match[0].length]); + if (match[0].length === 0) pattern.lastIndex++; + } + } + for (const range of wrappedEntropyRanges(window)) spans.push([offset + range.start, offset + range.end]); + for (const range of unwrappedEntropyRanges(window)) spans.push([offset + range.start, offset + range.end]); + return spans; +} + +type Rule = { kind: string; pattern?: RegExp; transform?: (text: string) => string; replace?: (match: string, ...groups: string[]) => string }; const marker = (kind: string): string => ``; const NOT_MASKED = '(?!|[:=])\\s*)(?:"${NOT_MASKED}[^"\\r\\n]{4,}"|'${NOT_MASKED}[^'\\r\\n]{4,}'|\`${NOT_MASKED}[^\`\\r\\n]{4,}\`|${NOT_MASKED}[^\\s"'\`<>,;]{4,})`, 'gu'), replace: (_match, prefix) => `${prefix}${marker('assignment')}` }, // A random run the terminal wrapped over lines, judged as one run. - { kind: 'high-entropy', pattern: new RegExp(`(? { const joined = match.replace(/[\s\u2500-\u257f|]/gu, ''); return joined.length >= 32 && looksRandom(joined) ? marker('high-entropy') : match; } }, + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, wrappedEntropyRanges(text)) }, // A long run that mixes upper case, lower case and digits with high entropy. Pure hex (a commit SHA) has no // upper case and survives; paths never form one run because `/` and `.` end it. - { kind: 'high-entropy', pattern: /(? looksRandom(match) ? marker('high-entropy') : match } + { kind: 'high-entropy', transform: text => maskEntropyRanges(text, unwrappedEntropyRanges(text)) } ]; -function looksRandom(value: string): boolean { - const upper = value.match(/[A-Z]/gu)?.length ?? 0, lower = value.match(/[a-z]/gu)?.length ?? 0, digits = value.match(/[0-9]/gu)?.length ?? 0; +/** + * Redact registered values and credential shapes against their original source ranges together. Applying the + * registry first can destroy a structural label (for example `PRIVATE KEY`, `password`, or `Authorization`) + * before its shape is recognized. Applying generic rules first has the inverse problem: a generic match can + * split a longer registered value. Keeping both sets of source ranges lets the renderer mask their union. + */ +function redactKnownAndCredentials(text: string, forms: KnownForms): string { + const streams: Generator[] = []; + if (forms.bare.length > 0 || forms.exact.length > 0) { + streams.push((function* (): Generator { + for (const match of knownMatches(text, forms)) yield { ...match, kind: 'secret', priority: 0 }; + })()); + } + streams.push(jsonSecretRanges(text)); + for (let index = 0; index < RULES.length; index++) { + const rule = RULES[index]!; + if (rule.pattern) streams.push(ruleSecretRanges(text, rule, 10 + index)); + } + streams.push(wrappedEntropyRanges(text)); + streams.push(unwrappedEntropyRanges(text)); + + let result = ''; + let copied = 0; + let active: KnownMatch | null = null; + let chosen: KnownMatch | null = null; + for (const candidate of orderedOccurrences(streams)) { + if (!candidate.kind || candidate.end <= candidate.start) continue; + if (active && candidate.start < active.end) { + active.end = Math.max(active.end, candidate.end); + if (preferMask(candidate, chosen!)) chosen = candidate; + continue; + } + if (active && chosen) { + result += text.slice(copied, active.start) + marker(chosen.kind!); + copied = active.end; + } + active = { start: candidate.start, end: candidate.end }; + chosen = candidate; + } + if (!active || !chosen) return text; + return result + text.slice(copied, active.start) + marker(chosen.kind!) + text.slice(active.end); +} + +/** Prefer the most complete source match, while preserving JSON's earlier guard against later generic rules. */ +function preferMask(candidate: KnownMatch, current: KnownMatch): boolean { + const candidateIsJson = candidate.priority === 1; + const currentIsJson = current.priority === 1; + const candidateIsGeneric = (candidate.priority ?? 0) >= 10; + const currentIsGeneric = (current.priority ?? 0) >= 10; + if (candidateIsJson && currentIsGeneric) return !privateKeyContainsJson(current, candidate); + if (currentIsJson && candidateIsGeneric) return privateKeyContainsJson(candidate, current); + const candidateLength = candidate.end - candidate.start; + const currentLength = current.end - current.start; + if (candidateLength !== currentLength) return candidateLength > currentLength; + return (candidate.priority ?? Number.MAX_SAFE_INTEGER) < (current.priority ?? Number.MAX_SAFE_INTEGER); +} + +/** PEM masking runs after the JSON-value pass and can still consume a nested JSON marker inside its body. */ +function privateKeyContainsJson(privateKey: KnownMatch, json: KnownMatch): boolean { + return privateKey.kind === 'private-key' && privateKey.start <= json.start && privateKey.end >= json.end + && (privateKey.start < json.start || privateKey.end > json.end); +} + +function* jsonSecretRanges(text: string): Generator { + const pattern = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + const prefix = match[1] ?? ''; + const closing = match[2] ?? ''; + const start = match.index + prefix.length; + const end = match.index + match[0].length - closing.length; + if (end > start) yield { start, end, kind: 'secret', priority: 1 }; + } +} + +function* ruleSecretRanges(text: string, rule: Rule, priority: number): Generator { + const originalPattern = rule.pattern!; + const flags = originalPattern.flags.includes('g') ? originalPattern.flags : `${originalPattern.flags}g`; + const pattern = new RegExp(originalPattern.source, flags); + for (let match = pattern.exec(text); match; match = pattern.exec(text)) { + let start = match.index; + let end = match.index + match[0].length; + switch (rule.kind) { + case 'authorization': + // Its replacement keeps the Authorization label and masks an optional Bearer/Basic/etc. scheme too. + start += (match[1] ?? '').length; + break; + case 'bearer': { + const prefix = /^Bearer\s+/u.exec(match[0]); + start += prefix?.[0].length ?? 0; + break; + } + case 'url-credentials': { + const scheme = match[1] ?? ''; + const userinfo = match[2] ?? ''; + // Keep the existing rule's allowance for a short, plain user name without a password. + if (!userinfo.includes(':') && userinfo.length < 16) continue; + start += scheme.length; + end = start + userinfo.length; + break; + } + case 'url-secret': + start += (match[1] ?? '').length; + break; + case 'assignment': { + // JSON_SECRET_VALUE runs first in the public pipeline. When it recognizes the same quoted key/value, + // its marker blocks this later assignment match and preserves the JSON quotes. + const jsonMatch = new RegExp(JSON_SECRET_VALUE.source, JSON_SECRET_VALUE.flags).exec(match[0]); + if (jsonMatch?.index === 0 && jsonMatch[0].length === match[0].length) continue; + start += (match[1] ?? '').length; + break; + } + } + if (end > start) yield { start, end, kind: rule.kind, priority }; + } +} + +function* wrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const firstEnd = entropyRunEnd(text, at); + if (firstEnd - at < 12) { + at = firstEnd; + continue; + } + let end = firstEnd; + let joinedLength = firstEnd - at; + let continuations = 0; + while (true) { + let next = end; + if (text.charCodeAt(next) === 0x0d && text.charCodeAt(next + 1) === 0x0a) next += 2; + else if (text.charCodeAt(next) === 0x0a) next++; + else break; + let decoration = 0; + while (decoration < 8 && next < text.length) { + const code = text.charCodeAt(next); + if (!(code === 0x20 || code === 0x09 || code === 0x7c || (code >= 0x2500 && code <= 0x257f))) break; + next++; + decoration++; + } + const nextEnd = entropyRunEnd(text, next); + if (nextEnd - next < 4 || !hasDigit(text, next, nextEnd)) break; + joinedLength += nextEnd - next; + continuations++; + end = nextEnd; + } + if (continuations > 0) { + if (joinedLength >= 32 && looksRandomRange(text, at, end, true)) { + yield { start: at, end, kind: 'high-entropy', priority: 10 + RULES.length }; + } + // A low-entropy wrapped candidate consumes its continuation too. + at = end; + } else at = firstEnd; + } +} + +function* unwrappedEntropyRanges(text: string): Generator { + for (let at = 0; at < text.length;) { + if (!isEntropyRunCharacter(text.charCodeAt(at)) || (at > 0 && isEntropyRunCharacter(text.charCodeAt(at - 1)))) { + at++; + continue; + } + const end = entropyRunEnd(text, at); + if (end - at >= 32 && looksRandomRange(text, at, end, false)) { + yield { start: at, end, kind: 'high-entropy', priority: 11 + RULES.length }; + } + at = end; + } +} + +function looksRandomRange(value: string, from: number, to: number, skipWrap: boolean): boolean { + let upper = 0, lower = 0, digits = 0; + for (let i = from; i < to && (upper < 2 || lower < 2 || digits < 2); i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + if (code >= 0x41 && code <= 0x5a) upper++; + else if (code >= 0x61 && code <= 0x7a) lower++; + else if (code >= 0x30 && code <= 0x39) digits++; + } if (upper < 2 || lower < 2 || digits < 2) return false; - const counts = new Map(); - for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1); + const counts = new Map(); + let length = 0; + for (let i = from; i < to; i++) { + const code = value.charCodeAt(i); + if (skipWrap && (code === 0x7c || isWrapCharacter(code))) continue; + counts.set(code, (counts.get(code) ?? 0) + 1); + length++; + } let entropy = 0; - for (const count of counts.values()) { const p = count / value.length; entropy -= p * Math.log2(p); } + for (const count of counts.values()) { const p = count / length; entropy -= p * Math.log2(p); } return entropy >= 4.2; } +const isEntropyRunCharacter = (code: number): boolean => code >= 0x30 && code <= 0x39 + || code >= 0x41 && code <= 0x5a || code >= 0x61 && code <= 0x7a + || code === 0x2b || code === 0x3d || code === 0x5f || code === 0x2d; + +function entropyRunEnd(text: string, from: number): number { + let end = from; + while (end < text.length && isEntropyRunCharacter(text.charCodeAt(end))) end++; + return end; +} + +function hasDigit(text: string, from: number, to: number): boolean { + for (let i = from; i < to; i++) { + const code = text.charCodeAt(i); + if (code >= 0x30 && code <= 0x39) return true; + } + return false; +} + +/** Render the same bounded entropy scans used when registered values overlap credential shapes. */ +function maskEntropyRanges(text: string, ranges: Iterable): string { + let result = ''; + let copied = 0; + for (const { start, end } of ranges) { + result += text.slice(copied, start) + marker('high-entropy'); + copied = end; + } + return copied === 0 ? text : result + text.slice(copied); +} + /** The generic shapes alone (no registered values). */ export function redactCredentials(text: string): string { if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : ''; let result = text; for (const rule of RULES) { + if (rule.transform) { + result = rule.transform(result); + continue; + } + if (!rule.pattern) continue; result = result.replace(rule.pattern, (match: string, ...rest: unknown[]) => { // Arguments after the match: the capture groups, then the numeric offset and the whole input. const end = rest.findIndex(value => typeof value === 'number'); @@ -420,3 +945,9 @@ export function redactCredentials(text: string): string { } return result; } + +/** The registry's pre-range pipeline when it has no held values to mask. */ +function redactJsonAndCredentials(text: string): string { + const withJsonValues = text.replace(JSON_SECRET_VALUE, (_match, prefix: string, closing: string) => `${prefix}${marker('secret')}${closing}`); + return redactCredentials(withJsonValues); +} diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index 2675e022..f6b1325c 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -87,11 +87,11 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'checkpoints.json', 'checkpoint-objects']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), - markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth', 'checkpoints', 'checkpoint-objects'] }; } diff --git a/src/main/services/shutdownSteps.ts b/src/main/services/shutdownSteps.ts new file mode 100644 index 00000000..67024971 --- /dev/null +++ b/src/main/services/shutdownSteps.ts @@ -0,0 +1,13 @@ +export interface ShutdownStep { + name: string; + run(): void | Promise; +} + +/** Cleanup remains ordered, but one failed save must not skip independent services. */ +export async function runShutdownSteps(steps: readonly ShutdownStep[], + warn: (message: string, error: unknown) => void = console.warn): Promise { + for (const step of steps) { + try { await step.run(); } + catch (error) { warn(`CanvasTTY shutdown: ${step.name} failed.`, error); } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 78821131..10e2152f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { BACKLOG_IPC, BACKLOG_TERMINAL_IPC, BACKLOG_EVENTS, type BacklogApi } from "../shared/backlog"; import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { AppSettings, @@ -182,8 +183,8 @@ const api: CanvasTTYApi = { onCardDecorations: (listener: (decorations: PluginCardDecorations) => void) => ( subscribe(IPC.pluginsCardDecorationsChanged, listener) ), - invokeCardAction: (pluginId: string, actionId: string, sessionId: string) => ( - ipcRenderer.invoke(IPC.pluginsInvokeCardAction, pluginId, actionId, sessionId) + invokeCardAction: (pluginId: string, actionId: string, sessionId: string, input?: Record) => ( + ipcRenderer.invoke(IPC.pluginsInvokeCardAction, pluginId, actionId, sessionId, input) ), launchFieldOptions: (pluginId: string, provider: ProviderId) => ( ipcRenderer.invoke(IPC.pluginsLaunchFieldOptions, pluginId, provider) @@ -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) ), @@ -271,6 +273,7 @@ const api: CanvasTTYApi = { ) }, terminal: { + onFocusRequested: (listener) => subscribe(BACKLOG_TERMINAL_IPC.focusRequested, listener), openFile: (id: string, reference: string) => ipcRenderer.invoke(IPC.terminalOpenFile, id, reference), fileDropText: (files: File[]) => terminalFileDropText( files.map((file) => webUtils.getPathForFile(file)), @@ -293,6 +296,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 a494320a..77bbd204 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]); @@ -1114,6 +1117,10 @@ export function App(): React.JSX.Element { setCamera(focusCamera(session.position, session.size)); }, []); + useEffect(()=>window.canvasTTY.terminal.onFocusRequested(id=>{ + const session=sessionsRef.current.find(row=>row.id===id);if(session)focusSession(session); + }),[focusSession]); + const resumeHistory = useCallback(async (item: AgentChatHistoryItem, center: Point): Promise => { const current = settingsRef.current; const pixelSkin = isPixelSkinThemeId(current.terminalBorderSkin) || isPixelSkinPackId(current.terminalBorderSkin); @@ -1482,9 +1489,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 3c743b2e..c7ab926a 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 && <> + +