From 2856267e45d45f101ca2832fbfbec91b587b98f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:55:07 +0800 Subject: [PATCH 01/93] feat(orchestration): support OpenCode worker model selection Allow supervised OpenCode workers to use per-launch model overrides through the existing launch-preference and receipt path. Preserve existing OpenCode agent arguments while replacing only model flags, and reject unsupported effort values explicitly. Keep Native Chat option exposure unchanged and update the worker CLI and orchestration guidance. --- docs/site/content/docs/cli/orchestration.mdx | 2 +- .../references/coordinator-loop.md | 2 +- src/cli/bundled-skill-guides.ts | 4 +-- src/cli/specs/orchestration-worker-specs.ts | 2 +- .../worker/worker-launch-preferences.test.ts | 33 ++++++++++++++++++ .../worker/worker-launch-preferences.ts | 10 +++--- ...nt-session-option-catalog-opencode.test.ts | 34 +++++++++++++++++++ .../agent-session-option-catalog-opencode.ts | 16 +++++++++ src/shared/agent-session-option-launch.ts | 12 +++++-- .../tui-agent-startup-session-options.test.ts | 18 ++++++++++ 10 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 src/shared/agent-session-option-catalog-opencode.test.ts create mode 100644 src/shared/agent-session-option-catalog-opencode.ts diff --git a/docs/site/content/docs/cli/orchestration.mdx b/docs/site/content/docs/cli/orchestration.mdx index adad28a76dca..b883186fb10e 100644 --- a/docs/site/content/docs/cli/orchestration.mdx +++ b/docs/site/content/docs/cli/orchestration.mdx @@ -44,7 +44,7 @@ orca orchestration worker-start --task --worktree new-child --name bill orca orchestration worker-start --task --worktree current --agent claude --model --effort high --json ``` -`--model` accepts opaque provider model IDs for Claude, Codex, and Cursor. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. +`--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and OpenCode. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. Wait for completions (process every message in a Delivery, then ack): diff --git a/skill-guides/orchestration/references/coordinator-loop.md b/skill-guides/orchestration/references/coordinator-loop.md index 48f4a799d307..b957bc23fcdb 100644 --- a/skill-guides/orchestration/references/coordinator-loop.md +++ b/skill-guides/orchestration/references/coordinator-loop.md @@ -21,7 +21,7 @@ when an older CLI rejects the flag. A nested worker must respect ## Launch preferences -For a fresh Claude, Codex, Cursor, or Antigravity terminal, `--model` accepts an opaque +For a fresh Claude, Codex, Cursor, Antigravity, or OpenCode terminal, `--model` accepts an opaque provider model ID. Pass it only when the user named a model; otherwise omit it so the worker inherits the user's configured agent default. Add `--effort` only when that model supports it: diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index d523cdf56d7d..99b9a4d7c291 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -69,10 +69,10 @@ const ORCA_PER_WORKSPACE_ENV_WINDOWS_SCRIPTS_REFERENCE_MARKDOWN = "# Windows loc const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n" // oxfmt-ignore -const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, or Antigravity terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID\n in the live preamble. Never reconstruct, translate, or broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or OpenCode terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\ncapability arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action.\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Dispatch capability, Task ID, and Dispatch ID.\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --dispatch-capability --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --dispatch-capability --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --dispatch-capability --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --dispatch-capability --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --dispatch-capability --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore -const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, or Antigravity terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" +const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or OpenCode terminal, `--model` accepts an opaque\nprovider model ID. Pass it only when the user named a model; otherwise omit it\nso the worker inherits the user's configured agent default. Add `--effort` only\nwhen that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\n```\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" // oxfmt-ignore const ORCHESTRATION_LEGACY_CONTRACT_MIGRATION_REFERENCE_MARKDOWN = "# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n" diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index f8ace0339349..c2ce8e736326 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -34,7 +34,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', - '--model supports Claude, Codex, Cursor, and Antigravity opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', + '--model supports Claude, Codex, Cursor, Antigravity, and OpenCode opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 7f885cb1a595..a2b12467ef1a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -67,6 +67,29 @@ describe('orchestration worker launch preferences', () => { ).toEqual({ model: 'gpt-5.6-sol' }) }) + it('passes an opaque OpenCode model through the launch catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash' + }) + ).toEqual({ + preferences: { model: 'zai-coding-plan/glm-5.3-flash' }, + receipt: { + requested: { + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash', + effort: null + }, + effective: { + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash', + effort: null + } + } + }) + }) + it.each([ { model: 'gpt-5.6-sol', @@ -138,6 +161,16 @@ describe('orchestration worker launch preferences', () => { } }) + it('rejects unsupported OpenCode effort instead of silently dropping it', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash', + effort: 'high' + }) + ).toThrow('Agent opencode model zai-coding-plan/glm-5.3-flash does not support effort high.') + }) + it('rejects effort without a model', () => { expect(() => resolveWorkerLaunchPreferences({ agent: 'codex', effort: 'high' })).toThrow( '--effort requires --model' diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index c89212c6725a..5aa804e8e197 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -1,10 +1,12 @@ import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' import { findCatalogModel, - findCatalogOption, - getAgentSessionOptionCatalog + findCatalogOption } from '../../../../../../shared/agent-session-option-catalog' -import { resolveAgentSessionOptionLaunch } from '../../../../../../shared/agent-session-option-launch' +import { + getAgentSessionOptionLaunchCatalog, + resolveAgentSessionOptionLaunch +} from '../../../../../../shared/agent-session-option-launch' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -66,7 +68,7 @@ export function resolveWorkerLaunchPreferences(args: { } } - const catalog = getAgentSessionOptionCatalog(args.agent) + const catalog = getAgentSessionOptionLaunchCatalog(args.agent) if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { throw new OrchestrationError( 'invalid_argument', diff --git a/src/shared/agent-session-option-catalog-opencode.test.ts b/src/shared/agent-session-option-catalog-opencode.test.ts new file mode 100644 index 000000000000..8ccf0e621769 --- /dev/null +++ b/src/shared/agent-session-option-catalog-opencode.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' + +describe('OpenCode launch options', () => { + it('does not expose worker-only launch options as an interactive session catalog', () => { + expect(getAgentSessionOptionCatalog('opencode')).toBeNull() + }) + + it('maps an opaque model id to the OpenCode --model flag', () => { + expect( + resolveAgentSessionOptionLaunch('opencode', { + model: 'zai-coding-plan/glm-5.3-flash' + }) + ).toEqual({ + args: ['--model', 'zai-coding-plan/glm-5.3-flash'], + appliedValues: { model: 'zai-coding-plan/glm-5.3-flash' } + }) + }) + + it('removes a configured model flag before applying a per-launch model', () => { + expect( + removeOverriddenAgentSessionArgs('opencode', { model: 'zai-coding-plan/glm-5.3-flash' }, [ + '--model', + 'opencode/global-default', + '--log-level', + 'DEBUG' + ]) + ).toEqual(['--log-level', 'DEBUG']) + }) +}) diff --git a/src/shared/agent-session-option-catalog-opencode.ts b/src/shared/agent-session-option-catalog-opencode.ts new file mode 100644 index 000000000000..694695b01192 --- /dev/null +++ b/src/shared/agent-session-option-catalog-opencode.ts @@ -0,0 +1,16 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog-types' + +const MODEL_FLAGS = ['-m', '--model'] as const + +// Why: worker-start needs launch serialization without exposing a new Native Chat option surface. +export const OPENCODE_LAUNCH_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + models: [], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, MODEL_FLAGS), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, MODEL_FLAGS) + } +} diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a8388..316ab72cc055 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,7 @@ import type { AgentType } from './agent-status-types' import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog-types' import type { SessionOptionValue } from './native-chat-session-options' export type ResolvedSessionOptionLaunch = { @@ -7,12 +9,18 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +export function getAgentSessionOptionLaunchCatalog( + agent: AgentType +): AgentSessionOptionCatalog | null { + return agent === 'opencode' ? OPENCODE_LAUNCH_OPTION_CATALOG : getAgentSessionOptionCatalog(agent) +} + export function removeOverriddenAgentSessionArgs( agent: AgentType, values: Record | null | undefined, tokens: readonly string[] ): string[] { - const catalog = getAgentSessionOptionCatalog(agent) + const catalog = getAgentSessionOptionLaunchCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null if (!catalog || !values || !modelId) { return [...tokens] @@ -34,7 +42,7 @@ export function resolveAgentSessionOptionLaunch( trailingAgentArgs: readonly string[] = [], includeCatalogDefaults = true ): ResolvedSessionOptionLaunch { - const catalog = getAgentSessionOptionCatalog(agent) + const catalog = getAgentSessionOptionLaunchCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null if (!catalog || !values || !modelId) { return { args: [], appliedValues: {} } diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index 48f756a84b8a..4e6969bb516b 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -72,6 +72,24 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'gemini-3.1-pro-high', effort: 'high' }) }) + it('keeps OpenCode agent arguments while replacing only the per-launch model', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'zai-coding-plan/glm-5.3-flash' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--share --model opencode/global-default' + }) + expect(plan?.launchCommand).toBe("opencode '--share' '--model' 'zai-coding-plan/glm-5.3-flash'") + expect(plan?.launchConfig.agentCommand).toBe( + "opencode '--share' '--model' 'opencode/global-default'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'zai-coding-plan/glm-5.3-flash' }) + }) + it('inserts worker preferences before an argument terminator', () => { const plan = buildAgentStartupPlan({ agent: 'codex', From 751d58ab0e0b0cb2721e206cb66a86b7fa43336c Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:46:56 -0700 Subject: [PATCH 02/93] fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../serialize-grid-transcript-replay.test.ts | 5 + ...e-1-18-32-timed-boot-hidden-pane.meta.json | 9 + ...1-18-32-timed-boot-hidden-pane.timing.json | 1 + ...pencode-1-18-32-timed-boot-hidden-pane.txt | 1 + ...opencode-1-18-32-timed-boot-slow.meta.json | 9 + ...encode-1-18-32-timed-boot-slow.timing.json | 1 + .../opencode-1-18-32-timed-boot-slow.txt | 1 + ...ncode-1-18-32-timed-first-launch.meta.json | 9 + ...ode-1-18-32-timed-first-launch.timing.json | 1 + .../opencode-1-18-32-timed-first-launch.txt | 1 + ...de-2-0-18-timed-boot-hidden-pane.meta.json | 9 + ...-2-0-18-timed-boot-hidden-pane.timing.json | 1 + ...opencode-2-0-18-timed-boot-hidden-pane.txt | 1 + .../agent-transcript-replay-test-harness.ts | 35 ++++ ...draft-paste-ready-scanner-chunking.test.ts | 191 ++++++++++++++++++ .../launched-agent-composer-readiness.test.ts | 170 ++++++++++++++++ .../launched-agent-composer-readiness.ts | 37 ++++ ...pencode-worker-composer-transcript.test.ts | 101 +++++++++ .../orca-runtime-activate-managed-worktree.ts | 4 +- ...ederation-launched-agent-readiness.test.ts | 129 ++++++++++++ .../orchestration/federation/federation.ts | 6 +- .../worker/local-worker-start.ts | 17 +- .../worker/zcode-worker-readiness.test.ts | 44 ++-- src/shared/draft-paste-ready-scanner.test.ts | 37 +++- src/shared/draft-paste-ready-scanner.ts | 35 +++- src/shared/tui-agent-config-types.ts | 2 + src/shared/tui-agent-config.ts | 13 +- 27 files changed, 818 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/draft-paste-ready-scanner-chunking.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 28f0eed4459e..32a32166417c 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // Captured boots expose the existing background/cursor restore loss; serializer code is unchanged. + 'opencode-1-18-32-timed-boot-hidden-pane': 16, + 'opencode-1-18-32-timed-boot-slow': 16, + 'opencode-1-18-32-timed-first-launch': 16, + 'opencode-2-0-18-timed-boot-hidden-pane': 14, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..c7e2132fa288 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:40:33.073309Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: answered like a hidden Orca pane (silent on OSC colour queries), as a background worker pane is. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 42.8 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..0701a967c642 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":42761.8,"chunks":[[785.0,205],[786.9,71],[791.9,8],[793.5,57],[793.9,32],[794.0,16],[794.0,16],[794.1,24],[794.2,24],[794.2,24],[794.3,36],[794.4,24],[794.5,24],[795.2,1024],[795.8,1024],[796.3,1024],[796.7,1024],[796.9,1024],[797.2,961],[811.9,1024],[812.5,1024],[813.1,1024],[813.6,1024],[814.1,1024],[814.7,937],[2523.9,1024],[2524.9,1024],[2525.2,1024],[2525.5,1024],[2525.8,1024],[2526.1,1024],[2526.3,353],[2541.6,13],[2609.5,934],[2610.0,938],[2610.6,871],[2611.3,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..903a8f0f6968 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json new file mode 100644 index 000000000000..b05bbf658fe7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:39:12.532885Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: the slowest recorded boot: 3.9 s of silence after bracketed paste is enabled, and the longest gap (161 ms) between the bare OpenCode title and the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 76.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json new file mode 100644 index 000000000000..25aa23bb5bd3 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":76886.4,"chunks":[[2454.9,276],[2478.3,8],[2513.8,1024],[2518.9,1024],[2531.6,1024],[2539.1,1024],[2581.2,1024],[2583.9,1024],[2587.4,411],[2593.0,1024],[2602.3,1024],[2607.8,1024],[2621.8,1024],[2622.5,1024],[2623.3,937],[6479.2,1024],[6485.0,1024],[6487.5,1024],[6490.0,1024],[6499.8,1024],[6502.7,1024],[6509.0,353],[6538.4,13],[6685.0,934],[6686.4,938],[6697.9,871],[6699.1,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt new file mode 100644 index 000000000000..748053e1c3ee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json new file mode 100644 index 000000000000..e0ad565f9350 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:33:44.169427Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: first launch on an empty home: no first-run dialog appears before the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 14.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json new file mode 100644 index 000000000000..9c1d94e0a7a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":14932.3,"chunks":[[894.6,205],[897.3,71],[906.1,8],[909.0,57],[909.3,32],[909.4,16],[909.5,16],[909.6,24],[909.6,24],[909.8,24],[909.9,36],[910.0,24],[910.1,24],[911.8,134],[915.0,1024],[916.4,1024],[917.0,1024],[917.5,1024],[918.0,1024],[918.6,1024],[933.4,1024],[934.1,1024],[934.7,1024],[935.3,1024],[935.8,1024],[936.4,937],[2731.2,1024],[2731.8,1024],[2732.2,1024],[2732.7,1024],[2733.1,1024],[2734.0,1024],[2734.4,353],[2766.9,13],[2847.0,934],[2847.6,938],[2848.1,871],[2848.6,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt new file mode 100644 index 000000000000..2baf054700fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..5399222bf7ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:50:47.405221Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.18 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: OpenCode 2 answered like a hidden Orca pane; show-cursor repeats on every frame after the box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 41.5 s; no bytes after the last chunk. Run as `opencode --standalone` from a 2.0.18 binary; Orca's opencode2 row launches `opencode2 --standalone`. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..2a95872c1809 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":41502.8,"chunks":[[307.5,280],[316.3,1024],[316.7,1024],[317.0,1024],[317.4,1024],[317.7,1024],[318.0,1022],[318.3,164],[318.3,24],[318.3,24],[326.8,1024],[327.5,1024],[328.0,1024],[328.6,1024],[329.1,1024],[329.7,937],[1339.7,1024],[1340.4,1024],[1341.1,1024],[1341.8,1024],[1342.5,1024],[1343.1,1024],[1343.8,353],[1348.0,13],[1387.9,934],[1388.4,938],[1388.7,832],[1418.2,483],[1436.2,483],[1453.8,488],[1471.8,497],[1489.7,498],[1508.0,504],[1525.8,504],[1543.2,504],[1560.9,504]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..b2b9cd01dfee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/USER1/orca-qa/b9b-capture/workshift+tab agentsctrl+p commands2.0.18]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35e7726bea9d..7a52bd875aa1 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -22,6 +22,41 @@ export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +export type TimedRuntimeFixture = { + /** The recorded PTY reads, in order; they concatenate to the whole `.txt`. */ + chunks: string[] + /** ms since spawn at which each read arrived. */ + times: number[] + promptSentAtMs?: number + /** When recording stopped; no bytes arrived between the last read and this. */ + recordedUntilMs?: number +} + +/** `.timing.json` holds each read as [ms since spawn, UTF-16 length]. */ +export function readTimedRuntimeFixture(name: string): TimedRuntimeFixture { + const data = readRuntimeFixture(name) + const timing: { + chunks: [number, number][] + promptSentAtMs?: number + recordedUntilMs?: number + } = JSON.parse(readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + if (offset !== data.length) { + throw new Error(`${name}.timing.json covers ${offset} of ${data.length} chars`) + } + return { + chunks, + times: timing.chunks.map(([at]) => at), + ...(timing.promptSentAtMs !== undefined ? { promptSentAtMs: timing.promptSentAtMs } : {}), + ...(timing.recordedUntilMs !== undefined ? { recordedUntilMs: timing.recordedUntilMs } : {}) + } +} + /** Resizes the grid before chunk `atChunk`, as a PTY resize landing mid-paint would. */ export type TranscriptReplayResize = { atChunk: number; cols: number; rows: number } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts new file mode 100644 index 000000000000..8fbbf19d552d --- /dev/null +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -0,0 +1,191 @@ +/** + * The draft-paste scanner must answer the same way however the PTY splits a stream into reads. + * Every committed capture whose signal revokes its anchor is replayed whole, one char at a time, + * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on + * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and + * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +type Walk = { anchor: string; end: string; marker: string } + +const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': { + anchor: '\x1b[?2004h', + end: '\x1b[?2004l', + marker: '\x1b[?25h' + }, + 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } +} + +// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +const ZSH_LAUNCH_PROLOGUE = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + +const OPENCODE_TIMED = [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch', + 'opencode-2-0-18-timed-boot-hidden-pane' +] + +type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } + +function readPtyTranscript(name: string): string { + return readFileSync( + join(__dirname, '..', 'daemon', '__fixtures__', 'pty-transcripts', name), + 'utf8' + ) +} + +function grokCase(name: string, trace: GrokStartupTraceChunk[]): Case { + const reads = trace.map((chunk) => chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + return { name, signal: 'grok-composer-prompt', data: reads.join(''), reads } +} + +const CASES: Case[] = [ + grokCase('grok 1.0.0 alt screen', GROK_STARTUP_PTY_TRACE), + grokCase('grok 1.0.0 inline', GROK_INLINE_STARTUP_PTY_TRACE), + { + name: 'dsh-tui-ready-no-key', + signal: 'dsh-composer-prompt', + data: readRuntimeFixture('dsh-tui-ready-no-key') + }, + { + name: 'zcode-composer-ready', + signal: 'zcode-composer-prompt', + data: readRuntimeFixture('zcode-composer-ready') + }, + { + name: 'opencode (untimed pty transcript)', + signal: 'render-cursor-after-bracketed-paste', + data: readPtyTranscript('opencode.txt') + }, + ...OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch`, + signal: 'render-cursor-after-bracketed-paste', + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) +] + +/** End offset of the first marker seen while the anchor is held, walking the whole string. */ +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { + let cursor = 0 + for (;;) { + const enter = data.indexOf(anchor, cursor) + if (enter === -1) { + return null + } + cursor = enter + anchor.length + const leave = data.indexOf(end, cursor) + const found = data.indexOf(marker, cursor) + if (found !== -1 && (leave === -1 || found + marker.length <= leave)) { + return found + marker.length + } + if (leave === -1) { + return null + } + cursor = leave + end.length + } +} + +/** [start, end) of the first read the scanner reports ready on, or null. */ +function scanReadyRead(signal: DraftPasteReadySignal, reads: string[]): [number, number] | null { + const scanner = createDraftPasteReadyScanner(signal) + let offset = 0 + for (const read of reads) { + if (scanner.observe(read).ready) { + return [offset, offset + read.length] + } + offset += read.length + } + return null +} + +function splitAt(data: string, sizes: () => number): string[] { + const reads: string[] = [] + for (let offset = 0; offset < data.length;) { + const size = sizes() + reads.push(data.slice(offset, offset + size)) + offset += size + } + return reads +} + +// mulberry32: a fixed seed keeps every chunking reproducible. +function seededSizes(seed: number, max: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return 1 + Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * max) + } +} + +function chunkings(testCase: Case): Map { + const { data } = testCase + const result = new Map([ + ['whole', [data]], + ['one char per read', splitAt(data, () => 1)] + ]) + if (testCase.reads) { + result.set('recorded reads', testCase.reads) + } + for (let seed = 0; seed < 12; seed += 1) { + const max = seed % 2 === 0 ? 16 : 2048 + result.set(`random chunking ${seed}`, splitAt(data, seededSizes(seed + 1, max))) + } + return result +} + +describe('draft-paste readiness does not depend on how the stream is chunked', () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { + const walk = WALKS[testCase.signal] + if (!walk) { + throw new Error(`no reference walk for ${testCase.signal}`) + } + const expected = walkReadyEnd(testCase.data, walk) + for (const [label, reads] of chunkings(testCase)) { + const readyRead = scanReadyRead(testCase.signal, reads) + if (expected === null) { + expect(readyRead, label).toBeNull() + continue + } + expect(readyRead, label).not.toBeNull() + const [start, end] = readyRead! + expect( + start < expected && expected <= end, + `${label}: [${start}, ${end}) vs ${expected}` + ).toBe(true) + } + }) + + it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { + const walk = WALKS['render-cursor-after-bracketed-paste']! + const data = readTimedRuntimeFixture(name).chunks.join('') + const alone = walkReadyEnd(data, walk) + expect(alone).not.toBeNull() + expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 000000000000..7f8e7a18fa15 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + readRuntimeFixture, + readTimedRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + getLaunchedAgentReadinessLane, + type LaunchedAgentReadinessLane +} from './launched-agent-composer-readiness' + +// Why a full Record: adding a TuiAgent fails to compile here until someone decides whether its +// worker start waits for a captured input-box marker, and a row that gains or loses evidence +// shows up in review. +const EXPECTED_LANES: Record = { + zcode: 'composer-marker', + opencode: 'composer-marker', + opencode2: 'composer-marker', + // mimo-code shares OpenCode's signal by parity only; no mimo boot has been recorded. + 'mimo-code': 'tui-idle', + // Grok's inline mode never fires its marker; its marker is not proven at the box for every mode. + grok: 'tui-idle', + dsh: 'tui-idle', + codex: 'tui-idle', + cursor: 'tui-idle', + pi: 'tui-idle', + omp: 'tui-idle', + droid: 'tui-idle', + hermes: 'tui-idle', + devin: 'tui-idle', + claude: 'tui-idle', + 'claude-agent-teams': 'tui-idle', + openclaude: 'tui-idle', + gemini: 'tui-idle', + antigravity: 'tui-idle', + aider: 'tui-idle', + openclaw: 'tui-idle', + copilot: 'tui-idle', + muse: 'tui-idle', + qoder: 'tui-idle', + codebuddy: 'tui-idle', + autohand: 'tui-idle', + ante: 'tui-idle', + trae: 'tui-idle', + 'prime-agent': 'tui-idle', + goose: 'tui-idle', + amp: 'tui-idle', + kilo: 'tui-idle', + kiro: 'tui-idle', + crush: 'tui-idle', + aug: 'tui-idle', + cline: 'tui-idle', + codebuff: 'tui-idle', + freebuff: 'tui-idle', + 'command-code': 'tui-idle', + continue: 'tui-idle', + kimi: 'tui-idle', + 'mistral-vibe': 'tui-idle', + 'qwen-code': 'tui-idle', + rovo: 'tui-idle' +} + +const FIXTURES = join(__dirname, '__fixtures__') +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => + (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) +) + +function isTuiAgent(agent: string): agent is TuiAgent { + return agent in TUI_AGENT_CONFIG +} + +function readSignal(agent: string) { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + if (!signal) { + throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) + } + return signal +} + +/** Index of the first read the scanner reports ready on, or -1. */ +function firstReadyRead(agent: string, reads: readonly string[]): number { + const scanner = createDraftPasteReadyScanner(readSignal(agent)) + return reads.findIndex((read) => scanner.observe(read).ready) +} + +function readReads(capture: string): string[] { + if (existsSync(join(FIXTURES, `${capture}.timing.json`))) { + return readTimedRuntimeFixture(capture).chunks + } + const data = readRuntimeFixture(capture) + const reads: string[] = [] + for (let offset = 0; offset < data.length; offset += 4096) { + reads.push(data.slice(offset, offset + 4096)) + } + return reads +} + +describe('which lane a freshly launched worker waits on', () => { + it.each(Object.entries(EXPECTED_LANES))('%s: %s', (agent, lane) => { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + expect(getLaunchedAgentReadinessLane(agent)).toBe(lane) + }) +}) + +describe('every capture a row cites proves its input-box marker', () => { + it('cites at least the rows worker start moved', () => { + expect(CITED_CAPTURES.map(([agent]) => agent)).toEqual( + expect.arrayContaining(['zcode', 'opencode', 'opencode2']) + ) + }) + + it.each(CITED_CAPTURES)('%s: %s is committed with its sidecar', (_agent, capture) => { + expect(existsSync(join(FIXTURES, `${capture}.txt`))).toBe(true) + const meta: { platform?: string; command?: string[]; note?: string } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + expect(meta.platform).toEqual(expect.any(String)) + expect(meta.command?.length).toBeGreaterThan(0) + expect(meta.note).toEqual(expect.any(String)) + }) + + it.each(CITED_CAPTURES)("%s: the row's current signal fires on %s", (agent, capture) => { + expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) + }) + + it.each( + CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') + )( + '%s: the first ready read in %s is the one that shows the input box', + async (agent, capture) => { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + const { chunks } = readTimedRuntimeFixture(capture) + const data = chunks.join('') + // OpenCode paints its box in one synchronized update, shown when that update ends. + const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + let boxRead = -1 + for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { + end += chunks[index].length + boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 + } + let placeholderRead = -1 + let read = 0 + for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { + if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { + placeholderRead = read + break + } + read += 1 + } + expect(placeholderRead).toBeGreaterThanOrEqual(0) + expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) + expect(firstReadyRead(agent, chunks)).toBe(boxRead) + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 000000000000..dedfeba8823e --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,37 @@ +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { OrcaRuntimeService } from './orca-runtime' + +export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' +> + +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' + ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) +} + +export function waitForWorkerAgentReady( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + args: { agent: TuiAgent | undefined; reusesTerminal: boolean; timeoutMs: number } +): Promise { + // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + return args.agent && !args.reusesTerminal + ? waitForLaunchedAgentComposer(runtime, handle, args.agent, args.timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: args.timeoutMs }) +} diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..52aa2f4da86e --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,101 @@ +/** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title +// and the launcher's cursor toggle are synthetic. +const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] +] + +/** The read that ends the synchronized update drawing OpenCode's input box. */ +function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) +} + +async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { composer: number | null; tuiIdle: number | null } = { + composer: null, + tuiIdle: null + } + let read = -1 + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => (settledAt.composer ??= read)) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { settledAt, composer, boxRead: boxRead(chunks) } +} + +describe('an OpenCode worker gets its task only once its input box exists', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles on the read that shows the box', + async (name, agent) => { + const { settledAt, composer, boxRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 2f7e59a6d1f8..0b50aa9a5d77 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, @@ -182,7 +183,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( @@ -198,6 +199,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts new file mode 100644 index 000000000000..4a6da545fbcd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +const HANDLE = 'term_windows_worker' +const WORKTREE = 'repo::windows-worktree' + +// Why: `worker-start --on ` launches the agent through this attach on the worker host, so +// it must wait for the same input box a local start waits for, or the task is typed too early. +describe('federated worker first dispatch readiness', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const db of databases.splice(0)) { + db.close() + } + vi.restoreAllMocks() + }) + + function createWorkerHost(): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + return runtime + } + + async function attach( + runtime: OrcaRuntimeService, + target: { agent: string } | { terminal: string } + ): Promise { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + const placement = + 'agent' in target + ? { worktree: 'new-top-level', repo: 'windows-repo', name: 'remote-worker', ...target } + : { worktree: WORKTREE, ...target } + return await method.handler( + method.params!.parse({ + runId: 'run-home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote worker', + protocolVersion: 3, + ...placement + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_remote', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_payload' + } + } + ) + } + + it.each(['opencode', 'opencode2', 'zcode'] as const)( + 'a %s terminal this attach created waits for its input box, not tui-idle', + async (agent) => { + const runtime = createWorkerHost() + + await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + HANDLE, + agent, + resolveWorkerStartReadinessTimeoutMs(undefined) + ) + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + const runtime = createWorkerHost() + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. + { id: WORKTREE } as never + ) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + + await expect(attach(runtime, { terminal: HANDLE })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + }) + + it('fails at agent_readiness without typing the task when the input box never appears', async () => { + const runtime = createWorkerHost() + vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: 'timeout' + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index ca63c1fb46d7..6bd678e2f6d9 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { @@ -217,8 +218,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } persistFederatedReadinessStage(setupStage) failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', + const wait = await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b5..049f6503ac15 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -188,17 +189,11 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 30d32c2ede83..9421596669d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -1,28 +1,38 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('ZCode first dispatch readiness', () => { const h = createOrchestrationWorkerReleaseHarness() afterEach(() => h.cleanup()) - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) it('keeps reused terminals on the normal idle wait', async () => { h.setup() diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index a37111a2e22c..feaa40fa260a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' @@ -87,6 +88,27 @@ describe('createDraftPasteReadyScanner', () => { }) }) + it('never joins a show-cursor across a chunk seam from bytes it already scanned', () => { + // Why: the stream holds only a hide-cursor; re-reading carried chars used to assemble a show. + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(DECSET_BRACKETED_PASTE).ready).toBe(false) + expect(scanner.observe('Search \x1b[?25').ready).toBe(false) + expect(scanner.observe('l more text').ready).toBe(false) + }) + + it('ignores a show-cursor after the shell turns bracketed paste back off to run a command', () => { + // zsh's prompt enables bracketed paste and disables it on accept-line, before the launcher + // runs; the launcher's cursor toggle stands in for any spinner (synthetic). + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}% opencode`).ready).toBe(false) + expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\n`).ready).toBe(false) + expect(scanner.observe(`${HIDE_CURSOR}resolving${SHOW_CURSOR}`).ready).toBe(false) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + it('ignores show-cursor that appears before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') // A pre-handshake cursor toggle must not trip readiness. @@ -272,17 +294,16 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('Windows ConPTY, which never forwards DECSET 2004', () => { - // Why: measured on a remote Windows host (terminal-agent-paste-bracketing.test.ts) — the - // agent's `\x1b[?2004h` is consumed by conhost and never reaches the client stream. Every - // signal below is anchored on it, so on Windows readiness cannot resolve and delivery always - // falls through to the caller's hard timeout and its blind process-ownership paste (#22479). - const WINDOWS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` + describe('a stream that never carries DECSET 2004', () => { + // Why: every signal below is anchored on `\x1b[?2004h`, so without it readiness cannot resolve + // and delivery falls through to the caller's hard timeout. A transport that loses the sequence + // lands here; terminal-agent-paste-bracketing.ts names remote replay and ConPTY as possible. + const ANCHORLESS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` it('never reports opencode ready from show-cursor frames alone', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') for (let frame = 0; frame < 5; frame += 1) { - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) @@ -291,7 +312,7 @@ describe('createDraftPasteReadyScanner', () => { it('never arms the default quiet window either', () => { const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste') - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f7a9cb7c0c52..c079c724c601 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,6 +4,7 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, @@ -48,7 +49,10 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const scanRevocableAnchorSegments = ( + window: string, + carriedLength: number, + anchor: string, + end: string + ): boolean => { let cursor = 0 while (cursor < window.length) { if (!sawMarkerAnchor) { @@ -191,7 +201,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) continue } const leaveIndex = window.indexOf(end, cursor) - const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex) + // Why: carried chars only rejoin a split anchor or leave; postAnchorRecent already holds + // them, so re-reading them could join a marker across the duplicated seam. + const segmentStart = Math.max(cursor, carriedLength) + const segment = + leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex) if ((postAnchorRecent + segment).includes(signalMarker ?? '')) { return true } @@ -251,9 +265,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned // output is never re-walked into a second enter/leave transition. + const carriedLength = anchorCarry.length const window = anchorCarry + data anchorCarry = window.slice(-ANCHOR_CARRY_CHARS) - if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) { + if (scanRevocableAnchorSegments(window, carriedLength, markerAnchor, markerAnchorEnd)) { return { ready: true, armQuietTimer: false } } } else if (!sawMarkerAnchor) { diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..6b3fcb464a48 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,6 +43,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Captured boots proving the composer marker used for fresh worker dispatch. */ + composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d9fdabb14c34..70e54675fff4 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch' + ] }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -108,7 +113,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] }, 'mimo-code': { detectCmd: 'mimo', @@ -318,7 +324,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', // Why: ZCode repaints an animated ASCII banner indefinitely, so the default quiet-render // window never settles; its composer box corner is the real "input is live" signal. - draftPasteReadySignal: 'zcode-composer-prompt' + draftPasteReadySignal: 'zcode-composer-prompt', + composerReadyCaptures: ['zcode-composer-ready'] }, devin: { detectCmd: 'devin', From 648da96f086d72116740f5991e31be4a07a75b70 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:52:50 -0700 Subject: [PATCH 03/93] feat(opencode): probe execution-host CLI capabilities --- .../opencode/opencode-cli-version.test.ts | 51 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 68 +++++++++++++++++++ src/shared/opencode-cli-version.test.ts | 29 ++++++++ src/shared/opencode-cli-version.ts | 26 +++++++ 4 files changed, 174 insertions(+) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 000000000000..3639f02d145f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 000000000000..29e4775a0a02 --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env).sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 000000000000..9423f6e31754 --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 000000000000..874958e135b0 --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + } +} From 8be3e1bfe9e528d8d89338c53859b762172608c3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:54:48 -0700 Subject: [PATCH 04/93] fix(opencode): select plugin default for execution host loader --- src/main/opencode/hook-plugin-module-contract.test.ts | 9 +++++++++ src/main/opencode/status-plugin-factory-source.ts | 7 ++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a0..f6d07361abe2 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde812..fd6c39bf510d 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), From f96b46c8e2ef7ef93bc97c16cc0f1d3d6ba6fe24 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:58:07 -0700 Subject: [PATCH 05/93] fix(opencode): limit prompt prefill capability to verified release --- src/shared/opencode-cli-version.test.ts | 5 +++++ src/shared/opencode-cli-version.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts index 9423f6e31754..cc9c5987f2e9 100644 --- a/src/shared/opencode-cli-version.test.ts +++ b/src/shared/opencode-cli-version.test.ts @@ -27,3 +27,8 @@ it('keeps future versions unknown instead of assuming a plugin loader or prompt promptMode: 'prefill' }) }) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts index 874958e135b0..2aa71b2c30f0 100644 --- a/src/shared/opencode-cli-version.ts +++ b/src/shared/opencode-cli-version.ts @@ -21,6 +21,6 @@ export function getOpenCodeCliCapabilities( return { version, pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', - promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' } } From de557e0f9eae455e5b11b87601426fb97c6e6318 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:10:27 -0700 Subject: [PATCH 06/93] feat(orchestration): gate OpenCode worker model preferences by host capability Co-authored-by: user141514 --- .../worker/worker-launch-preferences.test.ts | 31 +++++++++++++++-- .../worker/worker-launch-preferences.ts | 18 +++++++--- .../worker/worker-start-validation.ts | 6 ++++ ...nt-session-option-catalog-opencode.test.ts | 34 +++++++++++++++++++ .../agent-session-option-catalog-opencode.ts | 16 +++++++++ src/shared/agent-session-option-launch.ts | 12 +++++-- 6 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/shared/agent-session-option-catalog-opencode.test.ts create mode 100644 src/shared/agent-session-option-catalog-opencode.ts diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 94f7dfea24c1..fd38f103be64 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -79,10 +79,37 @@ describe('orchestration worker launch preferences', () => { ).toThrow('does not support effort turbo') }) - it('refuses an opencode model because the opencode 2 TUI rejects --model', () => { + it('refuses an opencode model until the execution host verifies legacy TUI support', () => { expect(() => resolveWorkerLaunchPreferences({ agent: 'opencode', model: 'meta/muse-spark-1.3' }) - ).toThrow('does not support launch-time model selection') + ).toThrow('cannot verify launch-time model selection') + }) + + it('supports an opaque OpenCode model on a verified legacy host', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'zai-coding-plan/glm-5.3-flash', + openCodeModelLaunchSupported: true + }) + ).toEqual({ + preferences: { model: 'zai-coding-plan/glm-5.3-flash' }, + receipt: { + requested: { agent: 'opencode', model: 'zai-coding-plan/glm-5.3-flash', effort: null }, + effective: { agent: 'opencode', model: 'zai-coding-plan/glm-5.3-flash', effort: null } + } + }) + }) + + it('refuses an unverified OpenCode effort on a legacy host', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'opencode/fledge-alpha-free', + effort: 'high', + openCodeModelLaunchSupported: true + }) + ).toThrow('does not support effort high') }) it('does not invent an effort when only a model is requested', () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index d998d8e5e715..555d6cd7b1df 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -1,10 +1,12 @@ import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' import { findCatalogModel, - findCatalogOption, - getAgentSessionOptionCatalog + findCatalogOption } from '../../../../../../shared/agent-session-option-catalog' -import { resolveAgentSessionOptionLaunch } from '../../../../../../shared/agent-session-option-launch' +import { + getAgentSessionOptionLaunchCatalog, + resolveAgentSessionOptionLaunch +} from '../../../../../../shared/agent-session-option-launch' import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -50,6 +52,7 @@ export function createPendingWorkerLaunchReceipt(args: { export function resolveWorkerLaunchPreferences(args: { agent: TuiAgent + openCodeModelLaunchSupported?: boolean model?: string effort?: string }): { @@ -66,7 +69,14 @@ export function resolveWorkerLaunchPreferences(args: { } } - const catalog = getAgentSessionOptionCatalog(args.agent) + if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) { + throw new OrchestrationError( + 'capability_unsupported', + 'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a verified legacy OpenCode CLI.' + ) + } + + const catalog = getAgentSessionOptionLaunchCatalog(args.agent) if (!catalog?.supportsWorkerLaunchPreferences || !catalog.modelApply.launchArgs) { throw new OrchestrationError( 'invalid_argument', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 1ecce8e557f8..59c248ef7539 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -52,6 +52,7 @@ export function prepareLocalWorkerStart(args: { params: WorkerStartInput createsWorktree: boolean runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean }): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) @@ -78,6 +79,7 @@ export function prepareLocalWorkerStart(args: { } return resolveWorkerStartAgent({ runtime, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, terminal: params.terminal, agent: params.agent, model: params.model, @@ -90,6 +92,7 @@ export function prepareFederationAttachmentWorkerStart(args: { params: FederationAttachStartInput createsWorktree: boolean runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean }): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { const { params, createsWorktree, runtime } = args assertWorkerLaunchPreferencesCreateTerminal(params) @@ -122,6 +125,7 @@ export function prepareFederationAttachmentWorkerStart(args: { } return resolveWorkerStartAgent({ runtime, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, terminal: params.terminal, agent: params.agent, model: params.model, @@ -133,6 +137,7 @@ export function prepareFederationAttachmentWorkerStart(args: { function resolveWorkerStartAgent(args: { runtime: OrcaRuntimeService + openCodeModelLaunchSupported?: boolean terminal?: string agent?: string model?: string @@ -149,6 +154,7 @@ function resolveWorkerStartAgent(args: { agent, launch: resolveWorkerLaunchPreferences({ agent, + openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, model: args.model, effort: args.effort }) diff --git a/src/shared/agent-session-option-catalog-opencode.test.ts b/src/shared/agent-session-option-catalog-opencode.test.ts new file mode 100644 index 000000000000..8ccf0e621769 --- /dev/null +++ b/src/shared/agent-session-option-catalog-opencode.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { + removeOverriddenAgentSessionArgs, + resolveAgentSessionOptionLaunch +} from './agent-session-option-launch' + +describe('OpenCode launch options', () => { + it('does not expose worker-only launch options as an interactive session catalog', () => { + expect(getAgentSessionOptionCatalog('opencode')).toBeNull() + }) + + it('maps an opaque model id to the OpenCode --model flag', () => { + expect( + resolveAgentSessionOptionLaunch('opencode', { + model: 'zai-coding-plan/glm-5.3-flash' + }) + ).toEqual({ + args: ['--model', 'zai-coding-plan/glm-5.3-flash'], + appliedValues: { model: 'zai-coding-plan/glm-5.3-flash' } + }) + }) + + it('removes a configured model flag before applying a per-launch model', () => { + expect( + removeOverriddenAgentSessionArgs('opencode', { model: 'zai-coding-plan/glm-5.3-flash' }, [ + '--model', + 'opencode/global-default', + '--log-level', + 'DEBUG' + ]) + ).toEqual(['--log-level', 'DEBUG']) + }) +}) diff --git a/src/shared/agent-session-option-catalog-opencode.ts b/src/shared/agent-session-option-catalog-opencode.ts new file mode 100644 index 000000000000..694695b01192 --- /dev/null +++ b/src/shared/agent-session-option-catalog-opencode.ts @@ -0,0 +1,16 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog-types' + +const MODEL_FLAGS = ['-m', '--model'] as const + +// Why: worker-start needs launch serialization without exposing a new Native Chat option surface. +export const OPENCODE_LAUNCH_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + models: [], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, MODEL_FLAGS), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, MODEL_FLAGS) + } +} diff --git a/src/shared/agent-session-option-launch.ts b/src/shared/agent-session-option-launch.ts index 53df967a8388..316ab72cc055 100644 --- a/src/shared/agent-session-option-launch.ts +++ b/src/shared/agent-session-option-launch.ts @@ -1,5 +1,7 @@ import type { AgentType } from './agent-status-types' import { findCatalogModel, getAgentSessionOptionCatalog } from './agent-session-option-catalog' +import { OPENCODE_LAUNCH_OPTION_CATALOG } from './agent-session-option-catalog-opencode' +import type { AgentSessionOptionCatalog } from './agent-session-option-catalog-types' import type { SessionOptionValue } from './native-chat-session-options' export type ResolvedSessionOptionLaunch = { @@ -7,12 +9,18 @@ export type ResolvedSessionOptionLaunch = { appliedValues: Record } +export function getAgentSessionOptionLaunchCatalog( + agent: AgentType +): AgentSessionOptionCatalog | null { + return agent === 'opencode' ? OPENCODE_LAUNCH_OPTION_CATALOG : getAgentSessionOptionCatalog(agent) +} + export function removeOverriddenAgentSessionArgs( agent: AgentType, values: Record | null | undefined, tokens: readonly string[] ): string[] { - const catalog = getAgentSessionOptionCatalog(agent) + const catalog = getAgentSessionOptionLaunchCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null if (!catalog || !values || !modelId) { return [...tokens] @@ -34,7 +42,7 @@ export function resolveAgentSessionOptionLaunch( trailingAgentArgs: readonly string[] = [], includeCatalogDefaults = true ): ResolvedSessionOptionLaunch { - const catalog = getAgentSessionOptionCatalog(agent) + const catalog = getAgentSessionOptionLaunchCatalog(agent) const modelId = typeof values?.model === 'string' ? values.model : null if (!catalog || !values || !modelId) { return { args: [], appliedValues: {} } From 32ac363224ac5e2a286a676b5ac0068aafde285d Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:36:12 -0700 Subject: [PATCH 07/93] feat(opencode): probe launch capabilities on the execution host --- .../opencode/opencode-cli-version.test.ts | 14 +++ src/main/opencode/opencode-cli-version.ts | 29 +++--- .../opencode-launch-capabilities.test.ts | 96 +++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 82 ++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index 3639f02d145f..b0886517da9d 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -49,3 +49,17 @@ it('degrades timeouts and missing executables to unknown', async () => { (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version ).toBeNull() }) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 29e4775a0a02..00d5cefe6a3b 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -11,6 +11,7 @@ export type OpenCodeCliVersionProbe = { env: NodeJS.ProcessEnv cwd?: string hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> } const probes = new Map }>() @@ -48,19 +49,21 @@ async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, + wsl: { distro: 'Ubuntu' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + env: { OPENCODE_CONFIG_DIR: '/guest/config' } + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + program: 'opencode', + args: ['--version'], + env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 000000000000..1d63cf0ed446 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,82 @@ +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + const guestEnv: Record = {} + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'OPENCODE_DISABLE_AUTOUPDATE' + ]) { + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + const distro = options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: () => + runWslProcess({ + distro, + loginPath: 'preferred', + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} From 6f8993aec422f38e838fd365c3619e22b56776d8 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:52:50 -0700 Subject: [PATCH 08/93] feat(opencode): probe execution-host CLI capabilities --- .../opencode/opencode-cli-version.test.ts | 51 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 68 +++++++++++++++++++ src/shared/opencode-cli-version.test.ts | 29 ++++++++ src/shared/opencode-cli-version.ts | 26 +++++++ 4 files changed, 174 insertions(+) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 000000000000..3639f02d145f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 000000000000..29e4775a0a02 --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env).sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 000000000000..9423f6e31754 --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 000000000000..874958e135b0 --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + } +} From 7a87a27d208208095cb5891b4a5bb3bbb1eb0910 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:36:12 -0700 Subject: [PATCH 09/93] feat(opencode): probe launch capabilities on the execution host --- .../opencode/opencode-cli-version.test.ts | 14 +++ src/main/opencode/opencode-cli-version.ts | 29 +++--- .../opencode-launch-capabilities.test.ts | 96 +++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 82 ++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index 3639f02d145f..b0886517da9d 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -49,3 +49,17 @@ it('degrades timeouts and missing executables to unknown', async () => { (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version ).toBeNull() }) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 29e4775a0a02..00d5cefe6a3b 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -11,6 +11,7 @@ export type OpenCodeCliVersionProbe = { env: NodeJS.ProcessEnv cwd?: string hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> } const probes = new Map }>() @@ -48,19 +49,21 @@ async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, + wsl: { distro: 'Ubuntu' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + env: { OPENCODE_CONFIG_DIR: '/guest/config' } + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + program: 'opencode', + args: ['--version'], + env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 000000000000..1d63cf0ed446 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,82 @@ +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + const guestEnv: Record = {} + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'OPENCODE_DISABLE_AUTOUPDATE' + ]) { + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + const distro = options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: () => + runWslProcess({ + distro, + loginPath: 'preferred', + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} From 7ed8f0f6aa9cc1de727777099d31bd8e779b9ef6 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:33:49 -0700 Subject: [PATCH 10/93] feat(orchestration): resolve explicitly configured command aliases --- ...a-runtime-get-terminal-interactive-wait.ts | 5 +++ .../configured-worker-agent-selector.test.ts | 42 ++++++++++++++++++ .../configured-worker-agent-selector.ts | 43 +++++++++++++++++++ .../worker/worker-start-validation.ts | 10 +++-- 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/orchestration/configured-worker-agent-selector.test.ts create mode 100644 src/main/runtime/orchestration/configured-worker-agent-selector.ts diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index a87f6cc03f20..1d16c7bcc715 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -14,6 +14,7 @@ import { selectExactWorkerProviderSession } from './orchestration/worker-provide import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { OrchestrationError } from './orchestration/orchestration-error' +import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAdoptTerminalOrphansFromInventory { async getTerminalInteractiveWait( @@ -178,6 +179,10 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd }) } + resolveOrchestrationAgentLauncher(selector: string): TuiAgent | undefined { + return resolveConfiguredWorkerAgent(selector, this.store?.getSettings().agentCmdOverrides ?? {}) + } + validateOrchestrationAgentLauncher(agent: TuiAgent): void { const settings = this.store?.getSettings() if (!settings) { diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts new file mode 100644 index 000000000000..d5cd8db7757b --- /dev/null +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { resolveConfiguredWorkerAgent } from './configured-worker-agent-selector' + +it('reuses the configured built-in grammar for a direct vendor executable', () => { + expect(resolveConfiguredWorkerAgent('codex-fugu', { codex: 'codex-fugu' })).toBe('codex') + expect(resolveConfiguredWorkerAgent('claude-fugu', { claude: 'claude-fugu' })).toBe('claude') +}) +it('keeps canonical IDs authoritative even when their command has an alias', () => { + expect(resolveConfiguredWorkerAgent('codex', { codex: 'codex-fugu' })).toBe('codex') +}) +it('requires explicit configuration instead of guessing a PATH command grammar', () => { + expect(resolveConfiguredWorkerAgent('codex-fugu', {})).toBeUndefined() + expect(resolveConfiguredWorkerAgent('node', { codex: 'node vendor.js' })).toBeUndefined() +}) +it('recognizes quoted native and Windows paths without changing the configured command', () => { + expect( + resolveConfiguredWorkerAgent( + 'opencode-private', + { + opencode: "'/tmp/agent directory/opencode-private'" + }, + 'darwin' + ) + ).toBe('opencode') + expect( + resolveConfiguredWorkerAgent( + 'codex-fugu', + { + codex: '"C:\\Agent Directory\\codex-fugu.exe"' + }, + 'win32' + ) + ).toBe('codex') +}) +it('refuses ambiguous aliases instead of selecting a different provider grammar', () => { + expect(() => + resolveConfiguredWorkerAgent('vendor', { + codex: 'vendor', + claude: 'vendor' + }) + ).toThrow('multiple launchers') +}) diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.ts new file mode 100644 index 000000000000..9499cff3d427 --- /dev/null +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.ts @@ -0,0 +1,43 @@ +import { basename, win32 } from 'node:path' +import type { TuiAgent } from '../../../shared/tui-agent' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import { + resolveStartupShell, + tokenizeStartupCommand +} from '../../../shared/tui-agent-startup-shell' +import { OrchestrationError } from './orchestration-error' + +export function resolveConfiguredWorkerAgent( + selector: string, + overrides: Partial>, + platform: NodeJS.Platform = process.platform +): TuiAgent | undefined { + if (isTuiAgent(selector)) { + return selector + } + const matches: TuiAgent[] = [] + for (const [agent, command] of Object.entries(overrides)) { + if (!isTuiAgent(agent) || !command) { + continue + } + const parsed = tokenizeStartupCommand(command, resolveStartupShell(platform)) + // A command wrapper cannot attest which CLI grammar its arguments implement. + if (!parsed.ok || parsed.tokens.length !== 1) { + continue + } + const executable = parsed.tokens[0] + const name = ( + executable.includes('\\') ? win32.basename(executable) : basename(executable) + ).replace(/\.(?:exe|cmd|bat)$/i, '') + if (name === selector) { + matches.push(agent) + } + } + if (matches.length > 1) { + throw new OrchestrationError( + 'agent_unconfigured', + `Agent command ${selector} is configured for multiple launchers. Use a canonical agent ID.` + ) + } + return matches[0] +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 59c248ef7539..25d0a7830518 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -40,7 +40,7 @@ export function validateFederatedWorkerStartPlacement( '--terminal reuses an existing agent and cannot combine with --agent.' ) } - if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { + if (!params.terminal && !params.agent) { throw new OrchestrationError( 'agent_unconfigured', 'A configured --agent is required when remote worker-start creates a terminal.' @@ -144,10 +144,14 @@ function resolveWorkerStartAgent(args: { effort?: string missingAgentMessage: string }): { agent: TuiAgent | undefined; launch: WorkerStartLaunch } { - if (!args.terminal && (!args.agent || !isTuiAgent(args.agent))) { + const agent = args.agent + ? isTuiAgent(args.agent) + ? args.agent + : args.runtime.resolveOrchestrationAgentLauncher?.(args.agent) + : undefined + if (!args.terminal && !agent) { throw new OrchestrationError('agent_unconfigured', args.missingAgentMessage) } - const agent = args.agent as TuiAgent | undefined if (agent) { args.runtime.validateOrchestrationAgentLauncher(agent) return { From 8f06210cf8234a80f5dd665dcf0333b445a6d7f0 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:52:31 -0700 Subject: [PATCH 11/93] fix(opencode): select plugin loader for the launched host binary --- src/main/ipc/pty/ipc/spawn-options.ts | 12 ++++ src/main/ipc/pty/runtime/spawn-options.ts | 13 ++++ src/main/opencode/opencode-pty-launch.test.ts | 72 +++++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 33 +++++++++ src/main/providers/pty-spawn-result.ts | 3 + src/relay/pty-handler.ts | 29 +++++++- 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef16..699cddda7582 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -64,6 +65,17 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..f5ddf87646fd 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -34,6 +34,7 @@ import { } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -102,6 +103,18 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 000000000000..2a1cfb4e7cd4 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) + +describe('execution-host OpenCode launch preparation', () => { + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 000000000000..cfd0f815f143 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,33 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: { ...process.env, ...env } + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b66..4eb5dc45a60d 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fbad4a212a0d..b175d4b6496b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -3,6 +3,9 @@ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchroni import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -216,6 +219,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -275,6 +279,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -1905,6 +1910,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1933,6 +1941,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -1979,6 +1988,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { + spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi + if (isRelayWslShell(shell)) { + addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + } await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2079,6 +2104,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2157,7 +2183,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } From c4a6af1506364b1af9126c988782fca678054afd Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:05:36 -0700 Subject: [PATCH 12/93] fix(opencode): match WSL probe cwd and declared guest environment --- .../opencode-launch-capabilities.test.ts | 55 +++++++++++++++++-- .../opencode/opencode-launch-capabilities.ts | 30 +++++++--- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 81eb123cbce4..6e4a09130631 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -66,31 +66,76 @@ describe('OpenCode execution-host launch capability probe', () => { expect(mocks.probe).not.toHaveBeenCalled() }) - it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } await probeOpenCodeLaunchCapabilities({ command: 'opencode --standalone', - env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, - wsl: { distro: 'Ubuntu' }, + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, hostIdentity: 'host-a' }) expect(mocks.resolve).not.toHaveBeenCalled() const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } expect(options).toEqual( expect.objectContaining({ executablePath: 'opencode', hostIdentity: 'host-a:wsl:Ubuntu', - env: { OPENCODE_CONFIG_DIR: '/guest/config' } + cwd: '/home/user/repo', + env: guestEnv }) ) await options.execute() expect(mocks.wsl).toHaveBeenCalledWith({ distro: 'Ubuntu', loginPath: 'preferred', + cwd: '/home/user/repo', program: 'opencode', args: ['--version'], - env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + env: guestEnv, timeoutMs: 5000, maxOutputBytes: 4096 }) }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: {} }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) }) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 1d63cf0ed446..86ade3f8af5e 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -1,3 +1,4 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getFirstCommandToken, getCommandTokenPathBasename @@ -41,25 +42,36 @@ export async function probeOpenCodeLaunchCapabilities(options: { } if (options.wsl) { const guestEnv: Record = {} - for (const key of [ - 'OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_CONFIG_DIR', - 'OPENCODE_DISABLE_AUTOUPDATE' - ]) { - const value = options.env[key] - if (value !== undefined) { - guestEnv[key] = value + const wslEnv = options.env.WSLENV + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } } } - const distro = options.wsl.distro + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro return probeOpenCodeCliVersion({ executablePath: executable, env: guestEnv, + cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, execute: () => runWslProcess({ distro, loginPath: 'preferred', + cwd, program: executable, args: ['--version'], env: guestEnv, From 979c322a1451ca1097b1fc0d889bbcf87ed21182 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:06:10 -0700 Subject: [PATCH 13/93] fix(orchestration): verify available OpenCode model on execution host --- .../opencode-model-availability.test.ts | 60 ++++++++++++ .../opencode/opencode-model-availability.ts | 40 ++++++++ ...a-runtime-get-terminal-interactive-wait.ts | 61 ++++++++++++ .../orchestration-opencode-model-host.test.ts | 93 +++++++++++++++++++ .../orchestration/federation/federation.ts | 4 +- .../worker/local-worker-start.ts | 32 ++++++- .../worker/worker-opencode-model-preflight.ts | 29 ++++++ .../worker/workers-new-worktree.test.ts | 30 ++++++ 8 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 src/main/opencode/opencode-model-availability.test.ts create mode 100644 src/main/opencode/opencode-model-availability.ts create mode 100644 src/main/runtime/orchestration-opencode-model-host.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts new file mode 100644 index 000000000000..9d0abfdeb03d --- /dev/null +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { probeOpenCodeModelAvailability } from './opencode-model-availability' +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) +const options = { + command: '/private/opencode', + model: 'opencode/fledge-alpha-free', + cwd: '/tmp/project', + env: { OPENCODE_CONFIG_DIR: '/private/config', XDG_DATA_HOME: '/private/account' } +} +describe('OpenCode model catalog validation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/resolved/opencode') + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + stdout: 'opencode/fledge-alpha-free\nopencode/big-pickle\n', + stderr: '', + signal: null + }) + }) + it('checks exact catalog membership using the execution scope', async () => { + expect(await probeOpenCodeModelAvailability(options)).toBe(true) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/resolved/opencode', + args: ['models'], + cwd: options.cwd, + env: options.env + }) + ) + }) + it('rejects the real invalid selector that falls back to Big Pickle', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, model: 'gpt-5-nano' })).toBe(false) + }) + it('does not accept substrings or verbose metadata', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, model: 'fledge-alpha-free' })).toBe( + false + ) + }) + it('refuses timed out catalog probes even with matching partial output', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: true, + stdout: options.model, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) + it('refuses WSL until its exact guest launch environment is available', async () => { + expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( + false + ) + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts new file mode 100644 index 000000000000..03ed405d1c0e --- /dev/null +++ b/src/main/opencode/opencode-model-availability.ts @@ -0,0 +1,40 @@ +import { getFirstCommandToken } from '../../shared/command-token-scanner' +import { runProcess } from '../../shared/child-process/run-process' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' + +export async function probeOpenCodeModelAvailability(options: { + command: string | undefined + model: string + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } +}): Promise { + // WSL needs the same guest account/profile environment as its actual launch. + if (options.wsl || !options.cwd) { + return false + } + const executable = await resolveCommandOnLocalPath(getFirstCommandToken(options.command ?? ''), { + env: options.env, + cwd: options.cwd + }) + if (!executable) { + return false + } + try { + const result = await runProcess({ + program: executable, + args: ['models'], + cwd: options.cwd, + env: options.env, + timeoutMs: 10_000, + maxOutputBytes: 1_048_576 + }) + return ( + result.code === 0 && + !result.timedOut && + result.stdout.split(/\r?\n/).some((line) => line === options.model) + ) + } catch { + return false + } +} diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 1d16c7bcc715..652c63f4470e 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -15,6 +15,12 @@ import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { OrchestrationError } from './orchestration/orchestration-error' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' +import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' +import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAdoptTerminalOrphansFromInventory { async getTerminalInteractiveWait( @@ -183,6 +189,61 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd return resolveConfiguredWorkerAgent(selector, this.store?.getSettings().agentCmdOverrides ?? {}) } + async probeOrchestrationOpenCodeModelLaunchSupport(target: { + worktree?: string + repo?: string + model?: string + }): Promise { + if (!target.model || (!target.repo && !target.worktree)) { + return false + } + const repo = target.repo ? await this.resolveRepoSelector(target.repo) : null + const workspace = repo ? null : await this.resolveTerminalWorkspaceLaunchScope(target.worktree) + const executionRepo = repo ?? workspace?.repo + if ( + repo?.connectionId || + workspace?.connectionId || + (executionRepo?.executionHostId && executionRepo.executionHostId !== 'local') + ) { + return false + } + const store = this.requireStore() + const settings = store.getSettings() + const path = repo?.path ?? workspace?.path + const unc = path ? parseWslUncPath(path) : null + const projectRuntime = executionRepo + ? resolveLocalProjectRuntimeForRepo(store, executionRepo) + : null + if (projectRuntime?.status === 'repair-required') { + return false + } + const wsl = unc + ? { distro: unc.distro } + : projectRuntime?.runtime.kind === 'wsl' + ? { distro: projectRuntime.runtime.distro } + : undefined + const platform = wsl ? 'linux' : process.platform + const command = + settings.agentCmdOverrides?.opencode || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, platform) + const env = { + ...process.env, + ...resolveTuiAgentLaunchEnv('opencode', settings.agentDefaultEnv) + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: 'opencode', + cwd: path, + wsl, + env, + hostIdentity: this.getRuntimeId() + }) + return ( + capabilities?.pluginApi === 'v1' && + (await probeOpenCodeModelAvailability({ command, model: target.model, cwd: path, wsl, env })) + ) + } + validateOrchestrationAgentLauncher(agent: TuiAgent): void { const settings = this.store?.getSettings() if (!settings) { diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts new file mode 100644 index 000000000000..d54158883ee9 --- /dev/null +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' +import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' +import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' + +vi.mock('../opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: vi.fn() +})) +vi.mock('../opencode/opencode-model-availability', () => ({ + probeOpenCodeModelAvailability: vi.fn() +})) +vi.mock('../project-runtime-git-options', () => ({ resolveLocalProjectRuntimeForRepo: vi.fn() })) + +function host( + scope: { path: string; connectionId?: string; repo?: { executionHostId?: string } } = { + path: '/tmp/folder' + } +) { + return { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => scope), + resolveRepoSelector: vi.fn(async () => scope), + requireStore: () => ({ + getSettings: () => ({ + agentCmdOverrides: { opencode: '/tmp/private-opencode' }, + agentDefaultEnv: { opencode: { OPENCODE_CONFIG_DIR: '/tmp/private-config' } } + }) + }), + getRuntimeId: () => 'host-1' + } +} +function probe( + runtime: ReturnType, + target = { worktree: 'id:folder', model: 'opencode/fledge-alpha-free' } +) { + return Reflect.apply( + OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport, + runtime, + [target] + ) +} + +describe('OpenCode worker model execution host', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(resolveLocalProjectRuntimeForRepo).mockReturnValue(undefined) + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '1.18.30', + pluginApi: 'v1', + promptMode: 'submit' + }) + }) + it('probes a local folder with its configured command and environment', async () => { + expect(await probe(host())).toBe(true) + expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/tmp/private-opencode', + cwd: '/tmp/folder', + hostIdentity: 'host-1', + env: expect.objectContaining({ OPENCODE_CONFIG_DIR: '/tmp/private-config' }) + }) + ) + }) + it('rejects an unknown selector rather than claiming the fallback model', async () => { + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) + expect(await probe(host())).toBe(false) + }) + it.each(['v2', 'unknown'] as const)('refuses %s CLI model selection', async (pluginApi) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: null, + pluginApi, + promptMode: 'unknown' + }) + expect(await probe(host())).toBe(false) + }) + it('never probes the client executable for an SSH workspace', async () => { + expect(await probe(host({ path: '/remote/folder', connectionId: 'ssh-1' }))).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + it('never probes the client executable for a foreign execution host', async () => { + expect( + await probe(host({ path: '/remote/folder', repo: { executionHostId: 'remote-host' } })) + ).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + it('probes the UNC workspace distro instead of native Windows', async () => { + expect(await probe(host({ path: '\\\\wsl.localhost\\Ubuntu\\home\\repo' }))).toBe(true) + expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith( + expect.objectContaining({ wsl: { distro: 'Ubuntu' } }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index ca63c1fb46d7..6a701e07ebf6 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,3 +1,4 @@ +import { prepareFederationWorkerLaunchOnHost } from '../worker/worker-opencode-model-preflight' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { @@ -21,7 +22,6 @@ import { } from './federation-setup' import { FederationAttachStartParams } from './federation-start-schema' import { failFederatedAttachmentWithReceipt } from './federation-start-receipt' -import { prepareFederationAttachmentWorkerStart } from '../worker/worker-start-validation' import { isWorkerStartTimeoutWithinTimerLimit, resolveWorkerStartReadinessTimeoutMs @@ -54,7 +54,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ ) } const createsWorktree = params.worktree === 'new-top-level' - const { agent, launch } = prepareFederationAttachmentWorkerStart({ + const { agent, launch } = await prepareFederationWorkerLaunchOnHost({ params, createsWorktree, runtime diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b5..3764064b753a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,3 +1,4 @@ +import { probeWorkerOpenCodeModelLaunchSupport } from './worker-opencode-model-preflight' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -54,7 +55,36 @@ export async function startLocalWorker(args: { const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) + const selectedAgent = params.agent + ? runtime.resolveOrchestrationAgentLauncher(params.agent) + : undefined + let openCodeModelLaunchSupported = false + if (selectedAgent === 'opencode' && params.model) { + const callerWorkspaceId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) + const creationParent = createsWorktree + ? await runtime.showManagedWorktree(`id:${callerWorkspaceId}`) + : undefined + openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( + runtime, + params, + createsWorktree + ? { repo: params.repo ?? creationParent?.repoId } + : { + worktree: + requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree + } + ) + } + const { agent, launch } = prepareLocalWorkerStart({ + params, + createsWorktree, + runtime, + openCodeModelLaunchSupported + }) const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId( runtime, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts new file mode 100644 index 000000000000..9e547ce0e99b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts @@ -0,0 +1,29 @@ +import { prepareFederationAttachmentWorkerStart } from './worker-start-validation' +import type { OrcaRuntimeService } from '../../../../orca-runtime' + +export async function probeWorkerOpenCodeModelLaunchSupport( + runtime: OrcaRuntimeService, + params: { agent?: string; model?: string }, + target: { worktree?: string; repo?: string } +): Promise { + return Boolean( + params.model && + params.agent && + runtime.resolveOrchestrationAgentLauncher(params.agent) === 'opencode' && + (await runtime.probeOrchestrationOpenCodeModelLaunchSupport({ ...target, model: params.model })) + ) +} + +export async function prepareFederationWorkerLaunchOnHost( + args: Omit< + Parameters[0], + 'openCodeModelLaunchSupported' + > +) { + const openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( + args.runtime, + args.params, + args.createsWorktree ? { repo: args.params.repo } : { worktree: args.params.worktree } + ) + return prepareFederationAttachmentWorkerStart({ ...args, openCodeModelLaunchSupported }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index fc7c1f3b26cf..ffc74fdd7773 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -189,6 +189,36 @@ describe('orchestration new-worktree workers', () => { }) }) + it('probes the creation repo before accepting an OpenCode model', async () => { + mockCreatedWorktree() + const probe = vi + .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') + .mockResolvedValue(true) + await expect( + startWorker({ agent: 'opencode', model: 'opencode/fledge-alpha-free' }) + ).resolves.toMatchObject({ + result: { + launch: { + effective: { agent: 'opencode', model: 'opencode/fledge-alpha-free', effort: null } + } + } + }) + expect(probe).toHaveBeenCalledWith({ repo: 'repo', model: 'opencode/fledge-alpha-free' }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ startupLaunchPreferences: { model: 'opencode/fledge-alpha-free' } }) + ) + }) + + it('rejects an unsupported OpenCode model before creating a worker', async () => { + const create = vi.spyOn(runtime, 'createManagedWorktree') + vi.spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport').mockResolvedValue(false) + await expect( + startWorker({ agent: 'opencode', model: 'opencode/fledge-alpha-free' }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(create).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', From d8f11f8405cb4c5d65d494d3c901e621c592ed99 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:18:07 -0700 Subject: [PATCH 14/93] Add host-owned OpenCode and Devin account profiles --- package.json | 1 + pnpm-lock.yaml | 5 + src/cli/handler-group-manifest.ts | 2 +- src/cli/handlers/account-list-format.ts | 37 ++++ src/cli/handlers/account.ts | 67 ++++--- .../handlers/data-account-commands.test.ts | 61 ++++++ src/cli/handlers/data-account-commands.ts | 151 +++++++++++++++ src/cli/help.ts | 2 +- src/cli/index.test.ts | 4 +- src/cli/root-help-text-primary.ts | 6 +- src/cli/specs/account.test.ts | 2 +- src/cli/specs/account.ts | 30 ++- .../daemon-bash-rcfile.txt | 8 + .../daemon-zsh-zshenv.txt | 8 + .../local-bash-rcfile.txt | 8 + .../local-zsh-zshenv.txt | 8 + .../relay-zsh-zshenv.txt | 8 + .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 + src/main/fish-xdg-data-dirs-handoff.ts | 2 + src/main/ipc/pty/host-env/assembly.ts | 2 + .../credential-capture.ts | 103 +++++++++++ .../launch-environment.ts | 39 ++++ .../managed-data-accounts/service.test.ts | 127 +++++++++++++ src/main/managed-data-accounts/service.ts | 174 ++++++++++++++++++ .../opencode-database-discovery.ts | 14 +- .../electron-serve-browser-process.test.ts | 6 + .../orcad/electron-serve-browser-process.ts | 3 + src/main/powershell-osc133-bootstrap.ts | 2 + .../local-pty-shell-ready-bash-rcfile.ts | 2 + src/main/runtime/rpc/methods/accounts.test.ts | 6 +- src/main/runtime/rpc/methods/accounts.ts | 30 +++ .../runtime/runtime-account-controller.ts | 49 ++++- .../runtime-service-command-surface.ts | 8 + src/main/shell-startup-features.ts | 1 + ...ll-wrapper-generated-file-snapshot.test.ts | 4 + src/main/zsh-startup-wrapper-builder.ts | 2 + src/shared/managed-account-types.ts | 14 ++ src/shared/managed-data-account-shell.ts | 26 +++ src/shared/protocol-version.ts | 2 + src/shared/rpc-contract/accounts-params.ts | 14 ++ .../rpc-params-catalog.generated.ts | 9 +- 41 files changed, 993 insertions(+), 56 deletions(-) create mode 100644 src/cli/handlers/account-list-format.ts create mode 100644 src/cli/handlers/data-account-commands.test.ts create mode 100644 src/cli/handlers/data-account-commands.ts create mode 100644 src/main/managed-data-accounts/credential-capture.ts create mode 100644 src/main/managed-data-accounts/launch-environment.ts create mode 100644 src/main/managed-data-accounts/service.test.ts create mode 100644 src/main/managed-data-accounts/service.ts create mode 100644 src/shared/managed-data-account-shell.ts diff --git a/package.json b/package.json index a64d7e212ad8..20da9dbc59a4 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "react-i18next": "17.0.15", "serve-sim": "0.1.47", "sherpa-onnx": "1.12.37", + "smol-toml": "1.8.0", "ssh2": "^1.17.0", "tldts": "7.4.14", "tweetnacl": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0f724a3eccb..45b913f63cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: sherpa-onnx: specifier: 1.12.37 version: 1.12.37 + smol-toml: + specifier: 1.8.0 + version: 1.8.0 ssh2: specifier: ^1.17.0 version: 1.17.0 @@ -9911,6 +9914,7 @@ snapshots: '@swc/core-win32-arm64-msvc': 1.15.46 '@swc/core-win32-ia32-msvc': 1.15.46 '@swc/core-win32-x64-msvc': 1.15.46 + optional: true '@swc/core@1.16.2': dependencies: @@ -9935,6 +9939,7 @@ snapshots: '@swc/types@0.1.27': dependencies: '@swc/counter': 0.1.3 + optional: true '@swc/types@0.1.28': dependencies: diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index bf1f1e313d1e..c27ea5108263 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -19,7 +19,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'account', - keys: ['account add', 'account list'], + keys: ['account add', 'account list', 'account select', 'account remove'], load: async () => (await import('./handlers/account.js')).ACCOUNT_HANDLERS }, { diff --git a/src/cli/handlers/account-list-format.ts b/src/cli/handlers/account-list-format.ts new file mode 100644 index 000000000000..2b76d7a0f1e8 --- /dev/null +++ b/src/cli/handlers/account-list-format.ts @@ -0,0 +1,37 @@ +import type { ManagedDataAccountsState } from '../../shared/managed-account-types' + +// Why: Claude and Codex managed-account summaries both carry id+email+active id, +// so one formatter renders either provider's block. +type AccountsBlock = { + accounts: readonly { id: string; email: string }[] + activeAccountId: string | null + activeAccountIdsByRuntime?: { + host: string | null + wsl: Record + } +} + +export function formatDataAccounts(label: string, state: ManagedDataAccountsState): string { + return `Managed ${label} accounts (${state.accounts.length}):\n${state.accounts + .map( + (account) => + ` ${account.id} ${account.label}${account.id === state.activeAccountId ? ' (active)' : ''}` + ) + .join('\n')}` +} + +/** Renders a provider's managed-account list as a human-readable block, marking the active account. */ +export function formatAccountsBlock(label: string, block: AccountsBlock): string { + if (block.accounts.length === 0) { + return `No managed ${label} accounts.` + } + const activeAccountIds = new Set([ + block.activeAccountId, + block.activeAccountIdsByRuntime?.host, + ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) + ]) + const lines = block.accounts.map( + (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` + ) + return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` +} diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index b2a06c6d2e25..2fdc394b87ea 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -28,47 +28,25 @@ import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version import type { RuntimeStatus } from '../../shared/runtime-types' import type { ClaudeRateLimitAccountsState, - CodexRateLimitAccountsState + CodexRateLimitAccountsState, + ManagedDataAccountsState } from '../../shared/managed-account-types' import { type InteractiveLoginSession, withInteractiveLoginCleanup } from './interactive-login-interruption' import { getWslAccountTarget } from './account-wsl-location' +import { addDataAccount, listDataAccounts, mutateDataAccount } from './data-account-commands' +import { formatAccountsBlock, formatDataAccounts } from './account-list-format' // Why: add returns just that provider's state; list returns the full snapshot. type AccountsListSnapshot = { + opencode?: ManagedDataAccountsState + devin?: ManagedDataAccountsState claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState } -// Why: Claude and Codex managed-account summaries both carry id+email+active id, -// so one formatter renders either provider's block. -type AccountsBlock = { - accounts: readonly { id: string; email: string }[] - activeAccountId: string | null - activeAccountIdsByRuntime?: { - host: string | null - wsl: Record - } -} - -/** Renders a provider's managed-account list as a human-readable block, marking the active account. */ -function formatAccountsBlock(label: string, block: AccountsBlock): string { - if (block.accounts.length === 0) { - return `No managed ${label} accounts.` - } - const activeAccountIds = new Set([ - block.activeAccountId, - block.activeAccountIdsByRuntime?.host, - ...Object.values(block.activeAccountIdsByRuntime?.wsl ?? {}) - ]) - const lines = block.accounts.map( - (account) => ` ${account.email}${activeAccountIds.has(account.id) ? ' (active)' : ''}` - ) - return `Managed ${label} accounts (${block.accounts.length}):\n${lines.join('\n')}` -} - function addAgentNodePaths(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const pathKey = process.platform === 'win32' && env.Path !== undefined && env.PATH === undefined @@ -310,13 +288,17 @@ export const ACCOUNT_HANDLERS: Record = { ) } const agent = agentFlag ?? 'claude' - if (agent !== 'claude' && agent !== 'codex') { + if (agent !== 'claude' && agent !== 'codex' && agent !== 'opencode' && agent !== 'devin') { throw new RuntimeClientError( 'invalid_argument', - `Unsupported --agent "${agent}". Use "claude" or "codex".` + `Unsupported --agent "${agent}". Use "claude", "codex", "opencode", or "devin".` ) } rejectAccountRemoteSelectionFlags(ctx, 'orca account add') + if (agent === 'opencode' || agent === 'devin') { + await addDataAccount(ctx, agent, runAgentLoginInTerminal) + return + } // Why: fail on runtime version skew before burning a full OAuth round trip. await assertAccountImportSupported(ctx) await ctx.client.call('accounts.list', { refreshUsage: false }) @@ -324,17 +306,32 @@ export const ACCOUNT_HANDLERS: Record = { }, 'account list': async (ctx) => { rejectAccountRemoteSelectionFlags(ctx, 'orca account list') + const provider = ctx.flags.get('agent') + if (provider !== undefined) { + await listDataAccounts(ctx, provider) + return + } const { client, json } = ctx // Why: this command renders no usage numbers, so skip the forced provider // refresh — it is one serial network round-trip per managed account. const result = await client.call('accounts.list', { refreshUsage: false }) - printResult( - result, - json, - (snapshot) => - `${formatAccountsBlock('Claude', snapshot.claude)}\n\n${formatAccountsBlock('Codex', snapshot.codex)}` + printResult(result, json, (snapshot) => + [ + formatAccountsBlock('Claude', snapshot.claude), + formatAccountsBlock('Codex', snapshot.codex), + ...(snapshot.opencode ? [formatDataAccounts('OpenCode', snapshot.opencode)] : []), + ...(snapshot.devin ? [formatDataAccounts('Devin', snapshot.devin)] : []) + ].join('\n\n') ) + }, + 'account select': async (ctx) => { + rejectAccountRemoteSelectionFlags(ctx, 'orca account select') + await mutateDataAccount(ctx, 'select') + }, + 'account remove': async (ctx) => { + rejectAccountRemoteSelectionFlags(ctx, 'orca account remove') + await mutateDataAccount(ctx, 'remove') } } diff --git a/src/cli/handlers/data-account-commands.test.ts b/src/cli/handlers/data-account-commands.test.ts new file mode 100644 index 000000000000..a440b9ccf88a --- /dev/null +++ b/src/cli/handlers/data-account-commands.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { RuntimeClient } from '../runtime-client' +import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { addDataAccount } from './data-account-commands' + +const client = new RuntimeClient(join(tmpdir(), 'orca-login-test'), 1000, null, null) +const context = { + client, + cwd: tmpdir(), + flags: new Map([['integration', 'opencode-go']]), + json: true, + rawArgs: [] +} + +afterEach(() => vi.restoreAllMocks()) + +describe('managed data account enrollment', () => { + it('refuses an old host before starting login', async () => { + vi.spyOn(client, 'call').mockResolvedValue({ + id: 'test', + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'test' } + }) + const login = vi.fn() + await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('Update or restart') + expect(login).not.toHaveBeenCalled() + }) + + it('isolates official login and removes credentials after failed capture', async () => { + const call = vi.spyOn(client, 'call') + call + .mockResolvedValueOnce({ + id: 'test', + ok: true, + result: { capabilities: [DATA_ACCOUNT_RUNTIME_CAPABILITY] }, + _meta: { runtimeId: 'test' } + }) + .mockRejectedValueOnce(new Error('capture failed')) + let directory = '' + const login = vi.fn(async (command: string, args: string[], env: Record) => { + expect(command).toBe('opencode') + expect(args).toEqual(['auth', 'login', 'opencode-go', '--standalone']) + directory = dirname(env.XDG_DATA_HOME) + expect(env.XDG_STATE_HOME).toBe(join(directory, 'state')) + expect(env.OPENCODE_AUTH_CONTENT).toBe('') + expect(env.OPENCODE_DB).toBe('opencode.db') + expect(existsSync(directory)).toBe(true) + }) + await expect(addDataAccount(context, 'opencode', login)).rejects.toThrow('capture failed') + expect(call).toHaveBeenLastCalledWith('accounts.addDataFromHome', { + provider: 'opencode', + sourceDataHome: join(directory, 'data'), + label: 'opencode' + }) + expect(existsSync(directory)).toBe(false) + }) +}) diff --git a/src/cli/handlers/data-account-commands.ts b/src/cli/handlers/data-account-commands.ts new file mode 100644 index 000000000000..0642992238f1 --- /dev/null +++ b/src/cli/handlers/data-account-commands.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' +import { DATA_ACCOUNT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import { + withInteractiveLoginCleanup, + type InteractiveLoginSession +} from './interactive-login-interruption' +import { getWslAccountTarget } from './account-wsl-location' +import { formatDataAccounts } from './account-list-format' + +export async function assertDataAccountsSupported(ctx: HandlerContext): Promise { + const status = await ctx.client.call('status.get') + if (!status.result.capabilities?.includes(DATA_ACCOUNT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Update or restart this Orca host to manage OpenCode and Devin accounts.' + ) + } +} + +export async function addDataAccount( + ctx: HandlerContext, + provider: ManagedDataAccountProvider, + login: ( + command: string, + args: string[], + extraEnv: Record, + json: boolean, + session: InteractiveLoginSession + ) => Promise +): Promise { + if (getWslAccountTarget(ctx.cwd)?.runtime === 'wsl') { + throw new RuntimeClientError( + 'invalid_argument', + 'Run this command inside the WSL host runtime; Windows-hosted WSL account import is not supported.' + ) + } + const label = ctx.flags.get('label') ?? provider + if (typeof label !== 'string' || !label.trim() || label.trim().length > 120) { + throw new RuntimeClientError('invalid_argument', '--label must contain 1–120 characters.') + } + await assertDataAccountsSupported(ctx) + const integration = ctx.flags.get('integration') + if ( + integration !== undefined && + (provider !== 'opencode' || typeof integration !== 'string' || !integration.trim()) + ) { + throw new RuntimeClientError( + 'invalid_argument', + '--integration requires an OpenCode integration ID or name.' + ) + } + const directory = mkdtempSync(join(tmpdir(), `orca-account-add-${provider}-`)) + const session: InteractiveLoginSession = { + child: null, + registering: false, + terminationPromise: null + } + const result = await withInteractiveLoginCleanup( + session, + async () => { + rmSync(directory, { recursive: true, force: true }) + }, + async () => { + const dataHome = join(directory, 'data') + await login( + provider, + provider === 'opencode' + ? [ + 'auth', + 'login', + ...(typeof integration === 'string' ? [integration] : []), + '--standalone' + ] + : ['auth', 'login', '--force-manual-token-flow'], + { + XDG_DATA_HOME: dataHome, + XDG_CONFIG_HOME: join(directory, 'config'), + XDG_CACHE_HOME: join(directory, 'cache'), + XDG_STATE_HOME: join(directory, 'state'), + ...(provider === 'opencode' + ? { + OPENCODE_CONFIG_DIR: join(directory, 'config', 'opencode'), + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + } + : {}) + }, + ctx.json, + session + ) + session.registering = true + return ctx.client.call('accounts.addDataFromHome', { + provider, + sourceDataHome: dataHome, + label: label.trim() + }) + } + ) + printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) +} + +export async function listDataAccounts(ctx: HandlerContext, provider: unknown): Promise { + if (provider !== 'opencode' && provider !== 'devin') { + throw new RuntimeClientError('invalid_argument', 'Use --agent opencode or --agent devin.') + } + await assertDataAccountsSupported(ctx) + const result = + await ctx.client.call>>( + 'accounts.listData' + ) + printResult(result, ctx.json, (snapshot) => { + const state = snapshot[provider] + if (!state) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'Managed accounts are unavailable on this host.' + ) + } + return formatDataAccounts(provider, state) + }) +} + +export async function mutateDataAccount( + ctx: HandlerContext, + action: 'select' | 'remove' +): Promise { + const provider = ctx.flags.get('agent') + const id = ctx.flags.get('account') + if ((provider !== 'opencode' && provider !== 'devin') || typeof id !== 'string' || !id) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use --agent opencode|devin and --account (system for the default selection).' + ) + } + await assertDataAccountsSupported(ctx) + const result = await ctx.client.call(`accounts.${action}Data`, { + provider, + accountId: action === 'select' && id === 'system' ? null : id + }) + printResult(result, ctx.json, (state) => formatDataAccounts(provider, state)) +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 8fc3b073faf9..c2153e665e4c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -178,7 +178,7 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { // Why: the shared --agent help describes launching a TUI agent in a terminal, // which is the wrong meaning here — this selects the account provider. if (command === 'account add' && flag === 'agent') { - return '--agent Account provider: claude or codex (default claude)' + return '--agent Account provider: claude, codex, opencode, or devin (default claude)' } if (flag === 'key' && command === 'computer hotkey') { return '--key Modifier chord with one key, e.g. CmdOrCtrl+A' diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 2a6c014b984e..259fce08ccab 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -443,10 +443,10 @@ describe('orca root help', () => { await main([], '/tmp/repo') expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account add Add a managed Claude or Codex account on this Orca host' + 'account add Add a managed agent account on this Orca host' ) expect(logSpy.mock.calls.flat().join('\n')).toContain( - 'account list List managed Claude and Codex accounts on this Orca host' + 'account list List managed agent accounts on this Orca host' ) logSpy.mockRestore() }) diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts index ac2a322fd864..6d6a73537e2f 100644 --- a/src/cli/root-help-text-primary.ts +++ b/src/cli/root-help-text-primary.ts @@ -18,8 +18,10 @@ export const ROOT_HELP_TEXT_PRIMARY = [ ' search Search the full text of agent sessions on one Orca host', '', 'Accounts:', - ' account add Add a managed Claude or Codex account on this Orca host', - ' account list List managed Claude and Codex accounts on this Orca host', + ' account add Add a managed agent account on this Orca host', + ' account list List managed agent accounts on this Orca host', + ' account select Select an OpenCode or Devin account for new launches', + ' account remove Remove an OpenCode or Devin account and its private data', '', 'Skills:', ' skills installed List installed skill selectors', diff --git a/src/cli/specs/account.test.ts b/src/cli/specs/account.test.ts index 7e285cf3b978..27fd5568b36a 100644 --- a/src/cli/specs/account.test.ts +++ b/src/cli/specs/account.test.ts @@ -33,7 +33,7 @@ describe('account command specs', () => { it('describes --agent as the account provider, not a terminal agent', () => { const help = formatCommandHelp(spec('account add')) - expect(help).toContain('Account provider: claude or codex (default claude)') + expect(help).toContain('Account provider: claude, codex, opencode, or devin (default claude)') expect(help).not.toContain('TUI agent') }) diff --git a/src/cli/specs/account.ts b/src/cli/specs/account.ts index 69dd0896e8de..0f1cc0306ec0 100644 --- a/src/cli/specs/account.ts +++ b/src/cli/specs/account.ts @@ -8,12 +8,15 @@ import { GLOBAL_FLAGS, type CommandSpec } from '../args' export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ { path: ['account', 'add'], - summary: 'Add a managed Claude or Codex account by signing in on this Orca host', - usage: 'orca account add [--agent claude|codex] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'agent'], + summary: 'Add a managed agent account by signing in on this Orca host', + usage: 'orca account add [--agent claude|codex|opencode|devin] [--label ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'label', 'integration'], notes: [ 'Runs the agent login (`claude login` / `codex login`) in this terminal, then registers the account with the local Orca runtime.', 'Codex uses device authorization so the browser can complete sign-in from a different machine.', + 'OpenCode 2 uses `opencode auth login --standalone` in private XDG directories. Devin uses `devin auth login --force-manual-token-flow`.', + 'Use --integration to skip the OpenCode integration picker; --label names the saved OpenCode or Devin profile.', + 'OpenCode and Devin profiles apply to new explicit host agent launches. Direct SSH relay and Windows-hosted WSL selection are not supported; run the command on a headless Orca runtime on that host.', 'Sign in with the account you want to add (e.g. use a private/incognito browser window for a second account).', '--agent defaults to claude. Requires the Orca runtime to be running on this machine.' ], @@ -21,12 +24,27 @@ export const ACCOUNT_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['account', 'list'], - summary: 'List managed Claude and Codex accounts on this Orca host', - usage: 'orca account list [--json]', - allowedFlags: [...GLOBAL_FLAGS], + summary: 'List managed agent accounts on this Orca host', + usage: 'orca account list [--agent opencode|devin] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent'], notes: [ 'Lists the accounts on this machine. `--environment` / `--pairing-code` are rejected rather than ignored; run it on the host whose accounts you want to see.' ], examples: ['orca account list'] + }, + { + path: ['account', 'select'], + summary: 'Select an OpenCode or Devin profile for new agent launches', + usage: 'orca account select --agent opencode|devin --account [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'] + }, + { + path: ['account', 'remove'], + summary: 'Remove a managed OpenCode or Devin profile and its private data', + usage: 'orca account remove --agent opencode|devin --account [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'agent', 'account'], + notes: [ + 'Deletes credentials and conversation data in the managed profile. Stop its running agents first. System credentials are never removed.' + ] } ] diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index bfbd9b420e26..2d161a012898 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -33,6 +33,14 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" +if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 12d37e0f724b..2187aaca0f02 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -71,6 +71,14 @@ __orca_deferred_init() { } __orca_restore_agent_teams_path [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index d947f956e481..fd151e03b12e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -43,6 +43,14 @@ fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" +if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 93fb79a907a9..7172b28d2dc2 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -78,6 +78,14 @@ __orca_deferred_init() { } __orca_restore_agent_teams_path [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 4299ebb42b50..102534564b78 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -44,6 +44,14 @@ __orca_deferred_init() { if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" + if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi + fi [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac # Why: OMP does not auto-load Orca's managed status extension; wrap only diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 76f05e9ddf58..6ae5c7f4c63d 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' import { BASH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK } from '../shell-templates' @@ -37,6 +38,7 @@ __orca_restore_agent_teams_path # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index bec472940442..472c3fe0ff9b 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -4,6 +4,7 @@ * that dir's fish/vendor_conf.d, and the snippet's first act is to undo it. */ import { getFishCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { MANAGED_DATA_ACCOUNT_FISH_RESTORE } from '../shared/managed-data-account-shell' import type { ShellWrapperFile } from './shell-wrapper-file-writer' /** Exactly what Orca prepended, so the snippet can remove that and nothing else. */ @@ -69,6 +70,7 @@ function __orca_fish_xdg_handoff status is-interactive; or return 0 function __orca_define_codex --on-event fish_prompt functions -e __orca_define_codex +${MANAGED_DATA_ACCOUNT_FISH_RESTORE} ${getFishCodexShellLaunchPreflight()} end end diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7a..1cae20bfcbd2 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -34,6 +34,7 @@ import { restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' +import { applyManagedDataAccountEnvironment } from '../../../managed-data-accounts/launch-environment' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -69,6 +70,7 @@ export function buildPtyHostEnv( ? undefined : resolvedOpenCodeConfigDir const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) + applyManagedDataAccountEnvironment(baseEnv, { ...opts, launchCommand: launchCommandHint }) const openCodeAgent = selectOpenCodeHookAgent( opts.launchAgent, launchCommandHint, diff --git a/src/main/managed-data-accounts/credential-capture.ts b/src/main/managed-data-accounts/credential-capture.ts new file mode 100644 index 000000000000..22de0bbe2e28 --- /dev/null +++ b/src/main/managed-data-accounts/credential-capture.ts @@ -0,0 +1,103 @@ +import { lstatSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { z } from 'zod' +import { parse } from 'smol-toml' +import SyncDatabase from '../sqlite/sync-database' +import { writeSecureFile } from '../../shared/secure-file' +import type { ManagedDataAccountProvider } from '../../shared/managed-account-types' + +const credential = z.discriminatedUnion('type', [ + z.object({ type: z.literal('key'), key: z.string().min(1) }), + z.object({ type: z.literal('api'), key: z.string().min(1) }), + z.object({ + type: z.literal('oauth'), + access: z.string().min(1), + refresh: z.string(), + expires: z.number().nonnegative() + }), + z.object({ type: z.literal('wellknown'), key: z.string().min(1), token: z.string().min(1) }) +]) + +function requireRegularFile(path: string): void { + const stat = lstatSync(path) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024) { + throw new Error('Credential capture requires a regular file smaller than 16 MiB.') + } +} + +export async function captureDataAccountCredentials( + provider: ManagedDataAccountProvider, + sourceDataHome: string, + destinationDataHome: string +): Promise { + if (provider === 'devin') { + const source = join(sourceDataHome, 'devin', 'credentials.toml') + requireRegularFile(source) + const content = readFileSync(source, 'utf8') + let parsed: unknown + try { + parsed = parse(content) + } catch { + throw new Error('Unsupported Devin credential format.') + } + if (!z.object({ windsurf_api_key: z.string().trim().min(1) }).safeParse(parsed).success) { + throw new Error('Devin login did not save supported credentials.') + } + if (!writeSecureFile(join(destinationDataHome, 'devin', 'credentials.toml'), content)) { + throw new Error('Could not restrict Devin credential file permissions.') + } + return ['devin'] + } + + const databasePath = join(sourceDataHome, 'opencode', 'opencode.db') + requireRegularFile(databasePath) + const database = new SyncDatabase(databasePath, { + readonly: true, + fileMustExist: true, + timeout: 1500 + }) + try { + database.pragma('query_only = ON') + const sessionTables = ['session', 'session_v2'].filter((name) => + database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) + ) + if (sessionTables.length === 0) { + throw new Error('Unsupported OpenCode credential database.') + } + // Both released session schemas must be empty before copying credentials. + for (const table of sessionTables) { + if (database.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get()) { + throw new Error( + 'Use an isolated OpenCode login directory; importing conversation databases is not supported.' + ) + } + } + const rows = database.prepare('SELECT integration_id, value FROM credential LIMIT 65').all() + if (rows.length === 0 || rows.length > 64) { + throw new Error('OpenCode login did not save a supported credential.') + } + const integrations = rows.map((row) => { + if (typeof row.integration_id !== 'string' || typeof row.value !== 'string') { + throw new Error('Unsupported OpenCode credential database.') + } + let value: unknown + try { + value = JSON.parse(row.value) + } catch { + throw new Error('Unsupported OpenCode credential format.') + } + if (!credential.safeParse(value).success) { + throw new Error('Unsupported OpenCode credential format.') + } + return row.integration_id + }) + const destination = join(destinationDataHome, 'opencode', 'opencode.db') + if (!writeSecureFile(destination, '')) { + throw new Error('Could not restrict OpenCode credential file permissions.') + } + await database.backup(destination) + return integrations + } finally { + database.close() + } +} diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts new file mode 100644 index 000000000000..382addedef3d --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -0,0 +1,39 @@ +import { + getCommandTokenPathBasename, + getFirstCommandToken +} from '../../shared/command-token-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { getManagedDataAccountService } from './service' + +export function applyManagedDataAccountEnvironment( + environment: Record, + options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean } +): void { + if (options.isWsl) { + return + } + const agent = + options.launchAgent ?? + getCommandTokenPathBasename(getFirstCommandToken(options.launchCommand ?? '')).replace( + /\.(?:exe|cmd|sh)$/i, + '' + ) + const provider = + agent === 'opencode' || agent === 'opencode2' ? 'opencode' : agent === 'devin' ? 'devin' : null + if (!provider) { + return + } + const selected = getManagedDataAccountService().launchEnvironment(provider) + if (!selected.XDG_DATA_HOME) { + return + } + Object.assign(environment, selected) + environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME + environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME + environment.ORCA_DATA_ACCOUNT_PROVIDER = provider + if (provider === 'opencode') { + // Database overrides and inline auth would bypass this profile's credentials. + environment.OPENCODE_AUTH_CONTENT = '' + environment.OPENCODE_DB = 'opencode.db' + } +} diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts new file mode 100644 index 000000000000..76cc162ea4a2 --- /dev/null +++ b/src/main/managed-data-accounts/service.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, afterEach, describe, expect, it } from 'vitest' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SyncDatabase from '../sqlite/sync-database' +import { ManagedDataAccountService } from './service' + +let root: string +let source: string +let service: ManagedDataAccountService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-data-accounts-test-')) + source = join(root, 'source') + mkdirSync(join(source, 'devin'), { recursive: true }) + writeFileSync(join(source, 'devin', 'credentials.toml'), 'windsurf_api_key = "test-only-key"\n') + service = new ManagedDataAccountService(join(root, 'managed')) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function openCodeSource(sessionTable = 'session'): void { + mkdirSync(join(source, 'opencode'), { recursive: true }) + const db = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + db.exec( + `CREATE TABLE ${sessionTable} (id TEXT); CREATE TABLE credential (integration_id TEXT, value TEXT)` + ) + db.prepare('INSERT INTO credential VALUES (?, ?)').run( + 'opencode-go', + JSON.stringify({ type: 'key', key: 'test-only-key' }) + ) + db.close() +} + +describe('managed data accounts', () => { + it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => { + const state = await service.add('devin', source, 'Work') + const id = state.accounts[0].id + expect(JSON.stringify(state)).not.toContain('test-only-key') + const environment = service.launchEnvironment('devin') + expect( + readFileSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'), 'utf8') + ).toContain('test-only-key') + if (process.platform !== 'win32') { + expect( + statSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml')).mode & 0o777 + ).toBe(0o600) + } + await service.select('devin', null) + expect(service.launchEnvironment('devin')).toEqual({}) + await service.select('devin', id) + await service.remove('devin', id) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + expect(existsSync(join(environment.XDG_DATA_HOME, 'devin', 'credentials.toml'))).toBe(false) + expect(existsSync(join(source, 'devin', 'credentials.toml'))).toBe(true) + }) + + it('captures OpenCode 2 SQLite credentials including WAL without leaking secrets', async () => { + openCodeSource('session_v2') + const writer = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + writer.pragma('journal_mode = WAL') + writer + .prepare('INSERT INTO credential VALUES (?, ?)') + .run('google', JSON.stringify({ type: 'key', key: 'second-test-key' })) + try { + const state = await service.add('opencode', source, 'Work') + expect(state.accounts[0].integrations).toEqual(['opencode-go', 'google']) + const env = service.launchEnvironment('opencode') + const captured = new SyncDatabase(join(env.XDG_DATA_HOME, 'opencode', 'opencode.db'), { + readonly: true + }) + expect(captured.prepare('SELECT COUNT(*) AS count FROM credential').get()?.count).toBe(2) + captured.close() + if (process.platform !== 'win32') { + expect(statSync(join(env.XDG_DATA_HOME, 'opencode', 'opencode.db')).mode & 0o777).toBe( + 0o600 + ) + } + } finally { + writer.close() + } + }) + + it('rejects importing personal conversation databases and rolls back the directory', async () => { + openCodeSource() + const db = new SyncDatabase(join(source, 'opencode', 'opencode.db')) + db.prepare('INSERT INTO session VALUES (?)').run('personal-session') + db.close() + await expect(service.add('opencode', source, 'Work')).rejects.toThrow('conversation databases') + expect(service.list('opencode').accounts).toEqual([]) + expect(readdirSync(join(root, 'managed', 'opencode'))).toEqual([]) + }) + + it('serializes overlapping enrollment so neither account is lost', async () => { + await Promise.all([service.add('devin', source, 'One'), service.add('devin', source, 'Two')]) + expect(service.list('devin').accounts.map((account) => account.label)).toEqual(['One', 'Two']) + }) + + it('rejects a credential symlink without touching its target', async () => { + const original = join(source, 'devin', 'credentials.toml') + const target = join(root, 'private.toml') + writeFileSync(target, readFileSync(original)) + rmSync(original) + symlinkSync(target, original) + await expect(service.add('devin', source, 'Work')).rejects.toThrow('regular file') + expect(readFileSync(target, 'utf8')).toContain('test-only-key') + }) + + it('keeps credential parse errors out of RPC messages', async () => { + writeFileSync( + join(source, 'devin', 'credentials.toml'), + 'windsurf_api_key = "secret-not-for-errors' + ) + await expect(service.add('devin', source, 'Work')).rejects.toThrow( + 'Unsupported Devin credential format.' + ) + }) +}) diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts new file mode 100644 index 000000000000..0c053333f82f --- /dev/null +++ b/src/main/managed-data-accounts/service.ts @@ -0,0 +1,174 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { join, resolve, sep } from 'node:path' +import { z } from 'zod' +import { getAppEnvironment } from '../../shared/app-environment' +import { writeSecureFile } from '../../shared/secure-file' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' +import { captureDataAccountCredentials } from './credential-capture' + +const stateSchema = z.object({ + accounts: z + .array( + z.object({ + id: z.uuid(), + label: z.string().min(1).max(120), + integrations: z.array(z.string()).max(64), + createdAt: z.number() + }) + ) + .max(64), + activeAccountId: z.uuid().nullable() +}) + +export class ManagedDataAccountService { + private pending: Promise = Promise.resolve() + private readonly listeners = new Set<() => void>() + + constructor(private readonly root: string) {} + + list(provider: ManagedDataAccountProvider): ManagedDataAccountsState { + const path = join(this.root, provider, 'accounts.json') + if (!existsSync(path)) { + return { accounts: [], activeAccountId: null } + } + this.assertOwned(path) + return stateSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + } + + add( + provider: ManagedDataAccountProvider, + sourceDataHome: string, + label: string + ): Promise { + return this.mutate(async () => { + const state = this.list(provider) + if (state.accounts.length >= 64) { + throw new Error('Managed account limit reached.') + } + const id = randomUUID() + const directory = join(this.root, provider, id) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + this.assertOwned(directory) + try { + const integrations = await captureDataAccountCredentials( + provider, + sourceDataHome, + join(directory, 'data') + ) + return this.persist(provider, { + accounts: [...state.accounts, { id, label, integrations, createdAt: Date.now() }], + activeAccountId: id + }) + } catch (error) { + rmSync(directory, { recursive: true, force: true }) + throw error + } + }) + } + + select( + provider: ManagedDataAccountProvider, + accountId: string | null + ): Promise { + return this.mutate(async () => { + const state = this.list(provider) + if (accountId !== null) { + this.requireAccount(provider, accountId) + } + return this.persist(provider, { ...state, activeAccountId: accountId }) + }) + } + + remove( + provider: ManagedDataAccountProvider, + accountId: string + ): Promise { + return this.mutate(async () => { + const state = this.list(provider) + this.requireAccount(provider, accountId) + const result = this.persist(provider, { + accounts: state.accounts.filter((account) => account.id !== accountId), + activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId + }) + const directory = join(this.root, provider, accountId) + this.assertOwned(directory) + rmSync(directory, { recursive: true, force: true }) + return result + }) + } + + launchEnvironment(provider: ManagedDataAccountProvider): Record { + const state = this.list(provider) + if (!state.activeAccountId) { + return {} + } + const directory = this.requireAccount(provider, state.activeAccountId) + return { + XDG_DATA_HOME: join(directory, 'data'), + XDG_STATE_HOME: join(directory, 'state'), + ...(provider === 'opencode' ? { OPENCODE_DB: 'opencode.db', OPENCODE_AUTH_CONTENT: '' } : {}) + } + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + private requireAccount(provider: ManagedDataAccountProvider, id: string): string { + if (!this.list(provider).accounts.some((account) => account.id === id)) { + throw new Error('Managed account not found.') + } + const directory = join(this.root, provider, id) + this.assertOwned(directory) + return directory + } + + private persist( + provider: ManagedDataAccountProvider, + state: ManagedDataAccountsState + ): ManagedDataAccountsState { + const checked = stateSchema.parse(state) + const path = join(this.root, provider, 'accounts.json') + if (existsSync(path)) { + this.assertOwned(path) + } + if (!writeSecureFile(path, JSON.stringify(checked))) { + throw new Error('Could not restrict account metadata permissions.') + } + for (const listener of this.listeners) { + listener() + } + return checked + } + + private assertOwned(path: string): void { + if ( + lstatSync(this.root).isSymbolicLink() || + lstatSync(path).isSymbolicLink() || + !realpathSync(path).startsWith(realpathSync(this.root) + sep) + ) { + throw new Error('Managed account path is outside Orca account storage.') + } + } + + private mutate(operation: () => Promise): Promise { + const next = this.pending.then(operation) + this.pending = next.catch(() => {}) + return next + } +} + +let instance: { root: string; service: ManagedDataAccountService } | undefined + +export function getManagedDataAccountService(): ManagedDataAccountService { + const root = resolve(getAppEnvironment().getPath('userData'), 'managed-data-accounts') + if (instance?.root !== root) { + instance = { root, service: new ManagedDataAccountService(root) } + } + return instance.service +} diff --git a/src/main/opencode-usage/opencode-database-discovery.ts b/src/main/opencode-usage/opencode-database-discovery.ts index fb406dc7a796..3525bfbdc84a 100644 --- a/src/main/opencode-usage/opencode-database-discovery.ts +++ b/src/main/opencode-usage/opencode-database-discovery.ts @@ -10,8 +10,11 @@ type OpenCodeDatabaseOverride = { path: string | null } -function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride { - const raw = process.env.OPENCODE_DB?.trim() +function getOpenCodeDatabaseOverride( + dataDirectory: string, + environment: NodeJS.ProcessEnv +): OpenCodeDatabaseOverride { + const raw = environment.OPENCODE_DB?.trim() if (!raw) { return { isConfigured: false, path: null } } @@ -30,10 +33,11 @@ function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOve export async function listOpenCodeDatabases( /** Lets a caller report the refusal; an empty list otherwise reads as * "OpenCode not used" rather than "we could not look". */ - onRefusal?: (path: string, error: WslTranscriptFsError) => void + onRefusal?: (path: string, error: WslTranscriptFsError) => void, + environment: NodeJS.ProcessEnv = process.env ): Promise { - const dataDirectory = resolveOpenCodeDataDirectory() - const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory) + const dataDirectory = resolveOpenCodeDataDirectory(environment) + const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory, environment) if (databaseOverride.isConfigured) { if (!databaseOverride.path) { return [] diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index 8ce23178f76b..e9e95a8fbd5a 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -172,6 +172,9 @@ describe('ElectronServeBrowserProcess start-up', () => { vi.stubEnv(key, `leaked-${key}`) } vi.stubEnv('ORCA_HARNESS_UNRELATED', 'preserved') + for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) { + vi.stubEnv(key, harnessRoot) + } const processHandle = await startProvider() @@ -189,6 +192,9 @@ describe('ElectronServeBrowserProcess start-up', () => { expect(spec.env).not.toHaveProperty(key) } expect(spec.env?.ORCA_HARNESS_UNRELATED).toBe('preserved') + for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) { + expect(spec.env).not.toHaveProperty(key) + } expect(processHandle.isAvailable()).toBe(true) }) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index f03406e1eb0b..733f9f86af32 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -55,6 +55,9 @@ async function reserveLoopbackPort(): Promise { function electronServeEnvironment(): NodeJS.ProcessEnv { const environment = { ...process.env } for (const key of [ + 'ORCA_E2E_USER_DATA_DIR', + 'ORCA_USER_DATA', + 'ORCA_USER_DATA_PATH', 'AGENT_BROWSER_ARGS', 'AGENT_BROWSER_AUTO_CONNECT', 'AGENT_BROWSER_CDP', diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 0dd39fa2a673..57d9c2959a0f 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,4 +1,5 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' +import { MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE } from '../shared/managed-data-account-shell' import { getPowerShellCodexShellLaunchPreflight } from '../shared/codex-shell-function' export { encodePowerShellCommand } from '../shared/powershell-command-encoding' @@ -39,6 +40,7 @@ const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerS # Profiles have already loaded normally by the time -EncodedCommand runs. # Restore managed ownership before the shell-integration compatibility guard. if ($env:ORCA_OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR = $env:ORCA_OPENCODE_CONFIG_DIR } +${MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE} if ($env:ORCA_MIMOCODE_HOME) { $env:MIMOCODE_HOME = $env:ORCA_MIMOCODE_HOME } if ($env:ORCA_CODEX_HOME) { $env:CODEX_HOME = $env:ORCA_CODEX_HOME } diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index 0bbcd0712ea0..d16715ed4966 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' @@ -50,6 +51,7 @@ ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" +${MANAGED_DATA_ACCOUNT_POSIX_RESTORE} [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" ${getPosixOmpShellWrapper()} # Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts. diff --git a/src/main/runtime/rpc/methods/accounts.test.ts b/src/main/runtime/rpc/methods/accounts.test.ts index ac8948422ac5..b8473d00e014 100644 --- a/src/main/runtime/rpc/methods/accounts.test.ts +++ b/src/main/runtime/rpc/methods/accounts.test.ts @@ -51,7 +51,11 @@ describe('account RPC methods', () => { it.each([ ['accounts.addClaudeFromConfigDir', { configDir: join(tmpdir(), 'claude-login') }], - ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }] + ['accounts.addCodexFromHome', { sourceHome: join(tmpdir(), 'codex-login') }], + [ + 'accounts.addDataFromHome', + { provider: 'opencode', sourceDataHome: join(tmpdir(), 'login'), label: 'Work' } + ] ])('rejects paired-device calls to %s', async (methodName, params) => { const runtime = { addClaudeAccountFromConfigDir: vi.fn(), diff --git a/src/main/runtime/rpc/methods/accounts.ts b/src/main/runtime/rpc/methods/accounts.ts index f7fe0af90ecc..9186e378da24 100644 --- a/src/main/runtime/rpc/methods/accounts.ts +++ b/src/main/runtime/rpc/methods/accounts.ts @@ -1,5 +1,8 @@ import { defineMethod, defineStreamingMethod } from '../core' import { + AddDataAccountParams, + SelectDataAccountParams, + RemoveDataAccountParams, AccountsUnsubscribeParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, @@ -25,6 +28,33 @@ let accountsSubscriptionSeq = 0 // `orca account add` CLI can register accounts on a headless host; it is gated // to the local runtime connection, never a mobile device token. See #1438. export const ACCOUNT_METHODS = [ + defineMethod({ + name: 'accounts.listData', + params: null, + handler: async (_, { runtime }) => runtime.getDataAccountsSnapshot() + }), + defineMethod({ + name: 'accounts.addDataFromHome', + params: AddDataAccountParams, + handler: async (params, { runtime, clientKind }) => { + if (clientKind !== undefined) { + throw new Error('Adding accounts is only available on the Orca host runtime.') + } + return runtime.addDataAccountFromHome(params.provider, params.sourceDataHome, params.label) + } + }), + defineMethod({ + name: 'accounts.selectData', + params: SelectDataAccountParams, + handler: async (params, { runtime }) => + runtime.selectDataAccount(params.provider, params.accountId) + }), + defineMethod({ + name: 'accounts.removeData', + params: RemoveDataAccountParams, + handler: async (params, { runtime }) => + runtime.removeDataAccount(params.provider, params.accountId) + }), defineMethod({ name: 'accounts.list', params: ListAccountsParams, diff --git a/src/main/runtime/runtime-account-controller.ts b/src/main/runtime/runtime-account-controller.ts index 45d3ade97ff7..e4f1cbfe8b5d 100644 --- a/src/main/runtime/runtime-account-controller.ts +++ b/src/main/runtime/runtime-account-controller.ts @@ -1,4 +1,10 @@ import type { ClaudeAccountService } from '../claude-accounts/service' +import { hasAppEnvironment } from '../../shared/app-environment' +import { getManagedDataAccountService } from '../managed-data-accounts/service' +import type { + ManagedDataAccountProvider, + ManagedDataAccountsState +} from '../../shared/managed-account-types' import type { CodexAccountService, CodexResetCreditRejectedBeforeProviderReason @@ -21,6 +27,8 @@ export type RuntimeAccountServices = { } export type AccountsSnapshot = { + opencode?: ManagedDataAccountsState + devin?: ManagedDataAccountsState claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState rateLimits: RateLimitState @@ -61,12 +69,43 @@ export class RuntimeAccountController { getSnapshot(): AccountsSnapshot { const { claudeAccounts, codexAccounts, rateLimits } = this.requireServices() return { + ...this.dataAccountsSnapshot(), claude: claudeAccounts.listAccounts(), codex: codexAccounts.listAccounts(), rateLimits: rateLimits.getState() } } + dataAccountsSnapshot(): Pick { + if (!hasAppEnvironment()) { + return {} + } + const service = getManagedDataAccountService() + return { opencode: service.list('opencode'), devin: service.list('devin') } + } + + addDataFromHome( + provider: ManagedDataAccountProvider, + sourceDataHome: string, + label: string + ): Promise { + return getManagedDataAccountService().add(provider, sourceDataHome, label) + } + + selectData( + provider: ManagedDataAccountProvider, + accountId: string | null + ): Promise { + return getManagedDataAccountService().select(provider, accountId) + } + + removeData( + provider: ManagedDataAccountProvider, + accountId: string + ): Promise { + return getManagedDataAccountService().remove(provider, accountId) + } + async refreshForMobile(): Promise { const { rateLimits } = this.requireServices() await Promise.allSettled([ @@ -151,13 +190,21 @@ export class RuntimeAccountController { onChanged(listener: (snapshot: AccountsSnapshot) => void): () => void { const services = this.requireServices() - return services.rateLimits.onStateChange((rateLimits) => { + const unsubscribeData = hasAppEnvironment() + ? getManagedDataAccountService().onChanged(() => listener(this.getSnapshot())) + : () => {} + const unsubscribeUsage = services.rateLimits.onStateChange((rateLimits) => { listener({ + ...this.dataAccountsSnapshot(), claude: services.claudeAccounts.listAccounts(), codex: services.codexAccounts.listAccounts(), rateLimits }) }) + return () => { + unsubscribeData() + unsubscribeUsage() + } } private requireServices(): RuntimeAccountServices { diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 82b3204da522..fb993e625cd8 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -41,6 +41,7 @@ export type RuntimeServiceCommandSurface = { registerMobilePushDevice: RuntimeMobileNotificationController['registerPushDevice'] unregisterMobilePushDevice: RuntimeMobileNotificationController['unregisterPushDevice'] setAccountServices: RuntimeAccountController['setServices'] + getDataAccountsSnapshot: RuntimeAccountController['dataAccountsSnapshot'] setCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['setCommitMessageAgentEnvironment'] getCommitMessageAgentEnvironmentResolvers: RuntimeAccountController['getCommitMessageAgentEnvironment'] getAccountsSnapshot: RuntimeAccountController['getSnapshot'] @@ -54,6 +55,9 @@ export type RuntimeServiceCommandSurface = { addClaudeAccountFromConfigDir: RuntimeAccountController['addClaudeFromConfigDir'] removeCodexAccount: RuntimeAccountController['removeCodex'] addCodexAccountFromHome: RuntimeAccountController['addCodexFromHome'] + addDataAccountFromHome: RuntimeAccountController['addDataFromHome'] + selectDataAccount: RuntimeAccountController['selectData'] + removeDataAccount: RuntimeAccountController['removeData'] onAccountsChanged: RuntimeAccountController['onChanged'] listMobileSpeechModels: RuntimeMobileSpeechCatalog['list'] downloadMobileSpeechModel: RuntimeMobileSpeechCatalog['download'] @@ -132,6 +136,7 @@ export function installRuntimeServiceCommandSurface( registerMobilePushDevice: notifications.registerPushDevice.bind(notifications), unregisterMobilePushDevice: notifications.unregisterPushDevice.bind(notifications), setAccountServices: accounts.setServices.bind(accounts), + getDataAccountsSnapshot: accounts.dataAccountsSnapshot.bind(accounts), setCommitMessageAgentEnvironmentResolvers: accounts.setCommitMessageAgentEnvironment.bind(accounts), getCommitMessageAgentEnvironmentResolvers: @@ -147,6 +152,9 @@ export function installRuntimeServiceCommandSurface( addClaudeAccountFromConfigDir: accounts.addClaudeFromConfigDir.bind(accounts), removeCodexAccount: accounts.removeCodex.bind(accounts), addCodexAccountFromHome: accounts.addCodexFromHome.bind(accounts), + addDataAccountFromHome: accounts.addDataFromHome.bind(accounts), + selectDataAccount: accounts.selectData.bind(accounts), + removeDataAccount: accounts.removeData.bind(accounts), onAccountsChanged: accounts.onChanged.bind(accounts), listMobileSpeechModels: speech.list.bind(speech), downloadMobileSpeechModel: speech.download.bind(speech), diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts index 95518967299e..1a883e9f769d 100644 --- a/src/main/shell-startup-features.ts +++ b/src/main/shell-startup-features.ts @@ -26,6 +26,7 @@ export type ShellStartupFeature = (typeof SHELL_STARTUP_FEATURES)[number] /** Spawn-env keys that mean this pane carries an Orca overlay the wrapper must re-apply. */ const OVERLAY_ENV_KEYS = [ + 'ORCA_DATA_ACCOUNT_DATA_HOME', 'ORCA_OPENCODE_CONFIG_DIR', 'ORCA_MIMOCODE_HOME', 'ORCA_OMP_STATUS_EXTENSION', diff --git a/src/main/shell-wrapper-generated-file-snapshot.test.ts b/src/main/shell-wrapper-generated-file-snapshot.test.ts index 36cd837e4fd9..9e86ebdd15ca 100644 --- a/src/main/shell-wrapper-generated-file-snapshot.test.ts +++ b/src/main/shell-wrapper-generated-file-snapshot.test.ts @@ -71,6 +71,10 @@ const CONTRACT_GLOBALS = new Set([ 'HISTFILE', 'MIMOCODE_HOME', 'OPENCODE_CONFIG_DIR', + 'OPENCODE_AUTH_CONTENT', + 'OPENCODE_DB', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', 'PATH', 'PROMPT_COMMAND', 'PS1', // Bash appends its non-printing Readline readiness marker. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 4fb4f8d01cf2..d6d6ef39a30d 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, * and relay. @@ -122,6 +123,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, OPENCODE_CONFIG_DIR_RESTORE, + MANAGED_DATA_ACCOUNT_POSIX_RESTORE, MIMOCODE_HOME_RESTORE, spec.restores.remoteCliBinDir ? REMOTE_CLI_BIN_DIR_RESTORE : null, getPosixOmpShellWrapper(), diff --git a/src/shared/managed-account-types.ts b/src/shared/managed-account-types.ts index 7239c62e6ad4..a4f7c272ac62 100644 --- a/src/shared/managed-account-types.ts +++ b/src/shared/managed-account-types.ts @@ -1,3 +1,17 @@ +export type ManagedDataAccountProvider = 'opencode' | 'devin' + +export type ManagedDataAccountSummary = { + id: string + label: string + integrations: string[] + createdAt: number +} + +export type ManagedDataAccountsState = { + accounts: ManagedDataAccountSummary[] + activeAccountId: string | null +} + export type CodexManagedAccount = { id: string email: string diff --git a/src/shared/managed-data-account-shell.ts b/src/shared/managed-data-account-shell.ts new file mode 100644 index 000000000000..357248220cac --- /dev/null +++ b/src/shared/managed-data-account-shell.ts @@ -0,0 +1,26 @@ +export const MANAGED_DATA_ACCOUNT_POSIX_RESTORE = `if [[ -n "\${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then + export XDG_DATA_HOME="\${ORCA_DATA_ACCOUNT_DATA_HOME}" + export XDG_STATE_HOME="\${ORCA_DATA_ACCOUNT_STATE_HOME}" + if [[ "\${ORCA_DATA_ACCOUNT_PROVIDER:-}" == opencode ]]; then + export OPENCODE_AUTH_CONTENT="" + export OPENCODE_DB="opencode.db" + fi +fi` + +export const MANAGED_DATA_ACCOUNT_POWERSHELL_RESTORE = `if ($env:ORCA_DATA_ACCOUNT_DATA_HOME) { + $env:XDG_DATA_HOME = $env:ORCA_DATA_ACCOUNT_DATA_HOME + $env:XDG_STATE_HOME = $env:ORCA_DATA_ACCOUNT_STATE_HOME + if ($env:ORCA_DATA_ACCOUNT_PROVIDER -eq 'opencode') { + $env:OPENCODE_AUTH_CONTENT = '' + $env:OPENCODE_DB = 'opencode.db' + } +}` + +export const MANAGED_DATA_ACCOUNT_FISH_RESTORE = ` if set -q ORCA_DATA_ACCOUNT_DATA_HOME; and test -n "$ORCA_DATA_ACCOUNT_DATA_HOME" + set -gx XDG_DATA_HOME "$ORCA_DATA_ACCOUNT_DATA_HOME" + set -gx XDG_STATE_HOME "$ORCA_DATA_ACCOUNT_STATE_HOME" + if test "$ORCA_DATA_ACCOUNT_PROVIDER" = opencode + set -gx OPENCODE_AUTH_CONTENT '' + set -gx OPENCODE_DB opencode.db + end + end` diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index b44e78b82fd8..b5e985cf3afc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -130,6 +130,7 @@ export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = 'worktree.archive-failure-blocking.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const +export const DATA_ACCOUNT_RUNTIME_CAPABILITY = 'accounts.managed-data-profiles.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -429,6 +430,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + DATA_ACCOUNT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, diff --git a/src/shared/rpc-contract/accounts-params.ts b/src/shared/rpc-contract/accounts-params.ts index 0a9559e95497..7fa31ee6b804 100644 --- a/src/shared/rpc-contract/accounts-params.ts +++ b/src/shared/rpc-contract/accounts-params.ts @@ -1,5 +1,19 @@ import { z } from 'zod' +export const ManagedDataAccountProviderParams = z.object({ + provider: z.enum(['opencode', 'devin']) +}) +export const AddDataAccountParams = ManagedDataAccountProviderParams.extend({ + sourceDataHome: z.string().min(1), + label: z.string().trim().min(1).max(120) +}) +export const SelectDataAccountParams = ManagedDataAccountProviderParams.extend({ + accountId: z.uuid().nullable() +}) +export const RemoveDataAccountParams = ManagedDataAccountProviderParams.extend({ + accountId: z.uuid() +}) + export const CodexResetTarget = z.discriminatedUnion('runtime', [ z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(), // Why: reset scope must identify one exact WSL distro; null means all slots only for selection. diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index ca4f857e0fab..8e86c13a0142 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -29,11 +29,14 @@ import { AccountsUnsubscribeParams, AddClaudeFromConfigDirParams, AddCodexFromHomeParams, + AddDataAccountParams, ConsumeCodexResetCreditParams, ListAccountsParams, RemoveAccountParams, + RemoveDataAccountParams, SelectAccountParams, - SelectCodexAccountForTargetParams + SelectCodexAccountForTargetParams, + SelectDataAccountParams } from './accounts-params' import { PrepareCodexForWslPaneParams } from './agent-hooks-params' import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params' @@ -559,13 +562,17 @@ import { export const RPC_PARAMS_BY_METHOD = { 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams, 'accounts.addCodexFromHome': AddCodexFromHomeParams, + 'accounts.addDataFromHome': AddDataAccountParams, 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams, 'accounts.list': ListAccountsParams, + 'accounts.listData': null, 'accounts.removeClaude': RemoveAccountParams, 'accounts.removeCodex': RemoveAccountParams, + 'accounts.removeData': RemoveDataAccountParams, 'accounts.selectClaude': SelectAccountParams, 'accounts.selectCodex': SelectAccountParams, 'accounts.selectCodexForTarget': SelectCodexAccountForTargetParams, + 'accounts.selectData': SelectDataAccountParams, 'accounts.subscribe': null, 'accounts.unsubscribe': AccountsUnsubscribeParams, 'agent.launch': AgentLaunch, From 7c22fa947f12e0985dfcb1ff799e7abe60c2464d Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:12:52 -0700 Subject: [PATCH 15/93] fix(orchestration): inspect OpenCode models with selected account environment --- ...a-runtime-get-terminal-interactive-wait.ts | 9 ++++++- .../orchestration-opencode-model-host.test.ts | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 652c63f4470e..3a0fd933e7f4 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -15,6 +15,7 @@ import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { OrchestrationError } from './orchestration/orchestration-error' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -226,10 +227,16 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd const command = settings.agentCmdOverrides?.opencode || getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, platform) - const env = { + const env: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...resolveTuiAgentLaunchEnv('opencode', settings.agentDefaultEnv) + })) { + if (value !== undefined) { + env[key] = value + } } + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode', isWsl: Boolean(wsl) }) const capabilities = await probeOpenCodeLaunchCapabilities({ command, agent: 'opencode', diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index d54158883ee9..3877ba4b43ff 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) vi.mock('../opencode/opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) @@ -43,6 +47,7 @@ function probe( describe('OpenCode worker model execution host', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(applyManagedDataAccountEnvironment).mockReset() vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) vi.mocked(resolveLocalProjectRuntimeForRepo).mockReturnValue(undefined) vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ @@ -62,6 +67,25 @@ describe('OpenCode worker model execution host', () => { }) ) }) + it('uses the same selected account environment for version and catalog probes', async () => { + vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { + env.XDG_DATA_HOME = '/private/selected-account' + env.OPENCODE_AUTH_CONTENT = '' + env.OPENCODE_DB = 'opencode.db' + }) + expect(await probe(host())).toBe(true) + const env = expect.objectContaining({ + XDG_DATA_HOME: '/private/selected-account', + OPENCODE_AUTH_CONTENT: '', + OPENCODE_DB: 'opencode.db' + }) + expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith(expect.objectContaining({ env })) + expect(probeOpenCodeModelAvailability).toHaveBeenCalledWith(expect.objectContaining({ env })) + expect(applyManagedDataAccountEnvironment).toHaveBeenCalledWith(expect.anything(), { + launchAgent: 'opencode', + isWsl: false + }) + }) it('rejects an unknown selector rather than claiming the fallback model', async () => { vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) expect(await probe(host())).toBe(false) From ae0a9d3288ee6d289e9a981b64accf5f5554f022 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:27:14 -0700 Subject: [PATCH 16/93] fix(orchestration): bind model validation to direct existing-workspace launch --- .../opencode-model-availability.test.ts | 7 +++++++ .../opencode/opencode-model-availability.ts | 17 +++++++++++++++-- ...rca-runtime-get-terminal-interactive-wait.ts | 10 ++++------ .../orchestration-opencode-model-host.test.ts | 11 +++++++++++ 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts index 9d0abfdeb03d..4cf3b0ff58fe 100644 --- a/src/main/opencode/opencode-model-availability.test.ts +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -51,6 +51,13 @@ describe('OpenCode model catalog validation', () => { }) expect(await probeOpenCodeModelAvailability(options)).toBe(false) }) + it.each(['opencode', '/private/opencode --config another-profile', 'echo;/private/opencode'])( + 'refuses an executable or configuration that differs from the direct launch: %s', + async (command) => { + expect(await probeOpenCodeModelAvailability({ ...options, command })).toBe(false) + expect(runProcess).not.toHaveBeenCalled() + } + ) it('refuses WSL until its exact guest launch environment is available', async () => { expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( false diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts index 03ed405d1c0e..b37f92dd7209 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-availability.ts @@ -1,4 +1,5 @@ -import { getFirstCommandToken } from '../../shared/command-token-scanner' +import { isAbsolute } from 'node:path' +import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { runProcess } from '../../shared/child-process/run-process' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' @@ -13,7 +14,19 @@ export async function probeOpenCodeModelAvailability(options: { if (options.wsl || !options.cwd) { return false } - const executable = await resolveCommandOnLocalPath(getFirstCommandToken(options.command ?? ''), { + const parsed = tokenizeStartupCommand( + options.command ?? '', + resolveStartupShell(process.platform) + ) + if ( + !parsed.ok || + parsed.tokens.length !== 1 || + parsed.spans.some((span) => span.divergesFromShell) || + !isAbsolute(parsed.tokens[0]) + ) { + return false + } + const executable = await resolveCommandOnLocalPath(parsed.tokens[0], { env: options.env, cwd: options.cwd }) diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 3a0fd933e7f4..7bf8d6f4f8fe 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -195,14 +195,12 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd repo?: string model?: string }): Promise { - if (!target.model || (!target.repo && !target.worktree)) { + if (!target.model || target.repo || !target.worktree) { return false } - const repo = target.repo ? await this.resolveRepoSelector(target.repo) : null - const workspace = repo ? null : await this.resolveTerminalWorkspaceLaunchScope(target.worktree) - const executionRepo = repo ?? workspace?.repo + const workspace = await this.resolveTerminalWorkspaceLaunchScope(target.worktree) + const executionRepo = workspace?.repo if ( - repo?.connectionId || workspace?.connectionId || (executionRepo?.executionHostId && executionRepo.executionHostId !== 'local') ) { @@ -210,7 +208,7 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd } const store = this.requireStore() const settings = store.getSettings() - const path = repo?.path ?? workspace?.path + const path = workspace?.path const unc = path ? parseWslUncPath(path) : null const projectRuntime = executionRepo ? resolveLocalProjectRuntimeForRepo(store, executionRepo) diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index 3877ba4b43ff..781c016556a9 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -86,6 +86,17 @@ describe('OpenCode worker model execution host', () => { isWsl: false }) }) + it('refuses creation repo catalogs before the actual destination exists', async () => { + expect( + await Reflect.apply( + OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport, + host(), + [{ repo: 'id:repo', model: 'opencode/fledge-alpha-free' }] + ) + ).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() + }) it('rejects an unknown selector rather than claiming the fallback model', async () => { vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) expect(await probe(host())).toBe(false) From 9356b6761b9d2e4dd202099653f674022ade15d0 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:39:15 -0700 Subject: [PATCH 17/93] fix(opencode): preserve launch environment deletion boundaries --- src/main/ipc/pty/ipc/spawn-options.ts | 1 + src/main/ipc/pty/runtime/spawn-options.ts | 1 + .../opencode-launch-capabilities.test.ts | 20 ++++ .../opencode/opencode-launch-capabilities.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 92 ++++++++++++++++++- src/main/opencode/opencode-pty-launch.ts | 16 +++- 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 699cddda7582..cf2d1c53b6ae 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -69,6 +69,7 @@ export async function buildPtyIpcSpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index f5ddf87646fd..0f4921cdf7dc 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -107,6 +107,7 @@ export async function buildRuntimePtySpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 6e4a09130631..9d15cefe80f2 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -13,9 +13,29 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) beforeEach(() => { vi.clearAllMocks() mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) }) describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) it('recognizes quoted executables and preserves explicit run commands', () => { expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 86ade3f8af5e..17a2a570872f 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -67,8 +67,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { env: guestEnv, cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, - execute: () => - runWslProcess({ + execute: async () => { + const result = await runWslProcess({ distro, loginPath: 'preferred', cwd, @@ -78,6 +78,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { timeoutMs: 5_000, maxOutputBytes: 4_096 }) + return result.environmentResolved ? result : { ...result, code: null } + } }) } const executablePath = options.resolveExecutable diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 2a1cfb4e7cd4..f7813ddc969e 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,13 +1,90 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + it.each(['1.1.23', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { @@ -21,6 +98,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode --prompt test', agent: 'opencode', env, + envToDelete: [], cwd: '/repo', isFreshLaunch: true }) @@ -39,7 +117,12 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) }) @@ -50,6 +133,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode', agent: 'opencode', env, + envToDelete: [], isFreshLaunch: true, wsl: { distro: 'Ubuntu' } }) @@ -64,7 +148,9 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index cfd0f815f143..5aa5adab0726 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,11 +1,13 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined agent?: TuiAgent env: Record | undefined + envToDelete: string[] cwd?: string connectionId?: string | null isFreshLaunch: boolean @@ -15,17 +17,29 @@ export async function prepareOpenCodePtyLaunch(options: { if (env) { delete env.ORCA_OPENCODE_PLUGIN_API } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } if (options.connectionId || !options.isFreshLaunch) { return env } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) const capabilities = await probeOpenCodeLaunchCapabilities({ ...options, - env: { ...process.env, ...env } + env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { return env } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } From ef0b55d0b269ddf9d600f252f6c7eb770a949552 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:39:45 -0700 Subject: [PATCH 18/93] fix(orchestration): limit effective model contract to verified OpenCode release --- .../orca-runtime-get-terminal-interactive-wait.ts | 2 +- .../runtime/orchestration-opencode-model-host.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 7bf8d6f4f8fe..2d3b4496d3f9 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -244,7 +244,7 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd hostIdentity: this.getRuntimeId() }) return ( - capabilities?.pluginApi === 'v1' && + capabilities?.version === '1.18.30' && (await probeOpenCodeModelAvailability({ command, model: target.model, cwd: path, wsl, env })) ) } diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index 781c016556a9..950f7ca7ffb5 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -86,6 +86,15 @@ describe('OpenCode worker model execution host', () => { isWsl: false }) }) + it('refuses unverified legacy versions even when they accept a model flag', async () => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(await probe(host())).toBe(false) + expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() + }) it('refuses creation repo catalogs before the actual destination exists', async () => { expect( await Reflect.apply( From 7dd9e82adc0381facb9ffb077c0c2bbc0d71de06 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:36:27 -0700 Subject: [PATCH 19/93] wip(opencode): authorize native startup prompt intent at execution owner --- .../server-startup-prompt-control.test.ts | 58 +++++ .../agent-hooks/server/server-lifecycle.ts | 31 ++- .../agent-hooks/server/server-listeners.ts | 5 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/opencode/opencode-pty-launch.test.ts | 90 +++++++- src/main/opencode/opencode-pty-launch.ts | 75 ++++++- .../opencode-startup-prompt-claims.test.ts | 49 +++++ .../opencode-startup-prompt-claims.ts | 70 ++++++ .../opencode/opencode-startup-prompt-owner.ts | 92 ++++++++ .../opencode-startup-prompt-source.test.ts | 207 ++++++++++++++++++ .../opencode-startup-prompt-source.ts | 111 ++++++++++ src/main/orcad/orcad-entry.ts | 8 +- src/main/runtime/orca-runtime-runtime-id.ts | 14 ++ src/main/runtime/terminal-run-facts.test.ts | 15 ++ src/main/runtime/terminal-run-facts.ts | 41 +++- src/main/startup/main-process-pty-startup.ts | 4 +- src/relay/pty-handler.ts | 17 ++ src/shared/opencode-startup-prompt.test.ts | 52 +++++ src/shared/opencode-startup-prompt.ts | 39 ++++ src/shared/tui-agent-startup.ts | 3 +- 25 files changed, 980 insertions(+), 26 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 000000000000..ebc96e63dea3 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' + +describe('startup prompt control with status hooks disabled', () => { + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e6e1bf80061d..49fb90321139 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -15,6 +15,7 @@ import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hoo import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { /** Start the loopback listener after hydration and spool replay have settled. */ @@ -22,10 +23,12 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,7 +40,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { + if (this.statusHooksEnabled && !this.ownerStateInitialized) { // Why: hydrate before binding the listener so an early hook POST runs against a populated map. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() @@ -84,6 +87,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -147,6 +160,11 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -187,7 +205,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -200,12 +220,17 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + if (this.statusHooksEnabled) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 961d3a3e9c87..ec03eefd98bc 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,6 +25,11 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index e5b9e4c13630..dfb35cae6be5 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 49bb8ade3ac9..2889ba293d97 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,6 +92,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11b..24df59c1201c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 0f4921cdf7dc..897fa37efa1b 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -103,7 +103,7 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -115,6 +115,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969e..27f327a4beac 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,6 +34,14 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { endpointFilePath: '/private/endpoint.env' } +})) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) @@ -35,13 +49,75 @@ beforeEach(() => probe.mockReset()) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +143,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +170,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +193,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +205,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +224,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab0726..314bec8770ed 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,5 +1,25 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' @@ -12,17 +32,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +68,47 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 000000000000..1dae2785a51b --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 000000000000..9dabf9ce426e --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,70 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + readOwner: () => TerminalRunFacts | null + cleanup: () => void + expiry: ReturnType +} + +/** A one-use launch authorization; the execution owner supplies the process facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { digest, readOwner, expiresAt: now + 20000, cleanup, expiry }) + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + this.cancel(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + return false + } + const owner = pending.readOwner() + return owner?.freshSpawn === true && owner.firstUserInputAt === null + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 000000000000..72068fc81d40 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,92 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +const claims = new OpenCodeStartupPromptClaims() + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OrcaRuntimeService } + result: PtySpawnResult + provider: IPtyProvider + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OrcaRuntimeService | undefined + provider: IPtyProvider + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + return + } + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + let unsubscribe = () => {} + if ( + claims.register( + nonce, + digest, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 000000000000..09ea92d80015 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,207 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and dispatches only until OpenCode clears the exact draft', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 000000000000..0a140a9ad079 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,111 @@ +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint) { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + const response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return false; + return (await response.json()).allowed === true; +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => settle(); + // OpenCode clears the editor before awaiting session creation; never retry a restored failure. + const changed = () => { if (seen && editor.plainText !== (populated ? prompt : "")) settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + ({ createHash } = await import("node:crypto")); + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== (populated ? prompt : "")) return settle(); + seen = true; + const agents = ctx.data.location.agent.list(ctx.location); + const models = ctx.data.location.model.list(ctx.location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + if (!claimed) { + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint); + claiming = false; + if (!allowed) return settle(); + claimed = true; + } + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + if (!populated) { + populated = true; + editor.insertText(prompt); + if (memory.settled || !matches(editor)) return settle(); + } + ctx.keymap.dispatch("prompt.submit"); + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b79..9178dfbf7625 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -200,9 +200,11 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dbdec24d0fc0..c856b91e3852 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -244,6 +244,20 @@ export class OrcaRuntimeWithRuntimeId { readonly terminalRunFacts = new TerminalRunFactsRegister() + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if ( + !pty || + pty.incarnationId !== incarnationId || + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + /** Both spawn-commit funnels report each committed process here, once. */ noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27c..1966a4059490 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4c..b1c029a767c1 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 648db87a3b61..5edfa51b110a 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -164,9 +164,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -175,6 +172,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b175d4b6496b..e68c12e2a8e8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -28,6 +28,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -1989,6 +1996,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 000000000000..e4aab26d0650 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain('run --prompt') + }) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 000000000000..1dbe9c2719d8 --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c41..ce8974a8cc33 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) } } From fc848877100c527ad86aba9c0cd356a38f6ddc47 Mon Sep 17 00:00:00 2001 From: Ahmed Nagy Date: Fri, 2 Oct 2026 00:50:32 -0700 Subject: [PATCH 20/93] fix(opencode): atomically replace status plugin entrypoints --- src/main/codex-accounts/fs-utils.ts | 77 +---- src/main/opencode/hook-service.ts | 30 +- src/relay/opencode-canonical-config.ts | 10 +- src/relay/plugin-overlay.test.ts | 4 +- src/relay/plugin-overlay.ts | 11 +- .../opencode-plugin-atomic-write.test.ts | 279 ++++++++++++++++++ src/shared/opencode-plugin-atomic-write.ts | 102 +++++++ .../opencode-tui-plugin-install.test.ts | 59 ++++ src/shared/opencode-tui-plugin-install.ts | 29 +- src/shared/windows-retry-file-operations.ts | 69 +++++ 10 files changed, 560 insertions(+), 110 deletions(-) create mode 100644 src/shared/opencode-plugin-atomic-write.test.ts create mode 100644 src/shared/opencode-plugin-atomic-write.ts create mode 100644 src/shared/opencode-tui-plugin-install.test.ts create mode 100644 src/shared/windows-retry-file-operations.ts diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 295dfac9b66b..0f96932ecb85 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,10 +1,15 @@ import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { rename } from 'node:fs/promises' +import { existsSync, linkSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' +import { + copyFileWithWindowsRetry, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from '../../shared/windows-retry-file-operations' + +export { copyFileWithWindowsRetry, renameFileWithWindowsRetry, renameFileWithWindowsRetryAsync } export function writeFileAtomically( targetPath: string, @@ -193,69 +198,3 @@ export function publishFileWithoutOverwrite(sourcePath: string, targetPath: stri throw error } } - -// Why: on Windows, file replacement and backup-copy operations can fail with -// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) -// holds the target file open. A short retry avoids transient failures without -// masking real permission errors. Total backoff (~750ms) covers typical AV -// scan windows seen in issue #1507. -export function renameFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => renameSync(source, target)) -} - -export async function renameFileWithWindowsRetryAsync( - source: string, - target: string, - isCurrent: () => boolean = () => true -): Promise { - for (let attempt = 1; ; attempt++) { - if (!isCurrent()) { - return false - } - try { - await rename(source, target) - return true - } catch (error) { - if (!shouldRetryFileOperation(error, attempt)) { - throw error - } - await setTimeout(attempt * 50) - } - } -} - -export function copyFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) -} - -function runFileOperationWithWindowsRetry(operation: () => void): void { - for (let attempt = 1; ; attempt++) { - try { - operation() - return - } catch (error) { - if (shouldRetryFileOperation(error, attempt)) { - sleepSync(attempt * 50) - continue - } - throw error - } - } -} - -function shouldRetryFileOperation(error: unknown, attempt: number): boolean { - return ( - process.platform === 'win32' && - attempt < 6 && - error instanceof Error && - 'code' in error && - (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') - ) -} - -// Why: writeFileAtomically is a sync API called from sync paths, so the retry -// backoff must park the thread instead of burning CPU in a Date.now() loop. -const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) -function sleepSync(ms: number): void { - Atomics.wait(sleepBuffer, 0, 0, ms) -} diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 88007d05c88b..d7ce098bcec4 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,4 +1,3 @@ -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -8,7 +7,6 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -35,6 +33,11 @@ import { export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' + const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' @@ -139,7 +142,7 @@ export class OpenCodeHookService { // Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane. this.writeTuiPlugin(pluginsDir, source) if (installed !== source) { - writeFileAtomically(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -279,20 +282,15 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. + // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) + this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. - } - writeFileSync(pluginPath, source) + writeOverlayOpenCodePluginAtomically(pluginPath, source) } } @@ -303,13 +301,17 @@ export class OpenCodeHookService { const source = this.pluginSource() this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } - private writeTuiPlugin(pluginsDir: string, source: string): void { + private writeTuiPlugin( + pluginsDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' + ): void { if (this.installsTuiPlugin) { - writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source, ownership) } } } diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 8cdfa7d5536d..21ed93a3d769 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' +import { writeCanonicalOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { writeOpenCodeTuiPlugin } from '../shared/opencode-tui-plugin-install' const RELAY_HOOKS_DIR = '.orca-relay' @@ -26,12 +27,7 @@ export function installOpenCodePluginInCanonicalConfig( mkdirSync(join(configDir, 'plugins'), { recursive: true }) writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. - } - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } return true } catch (err) { diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 9129e202287b..6694627e9aeb 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -137,9 +137,9 @@ describe('PluginOverlayManager', () => { manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) - expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') - expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin, next release') } ) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 35455d06859e..6d78918c46e0 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -24,9 +24,9 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' +import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' @@ -214,13 +214,8 @@ export class PluginOverlayManager { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, pluginFileName) - writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) - try { - unlinkSync(pluginPath) - } catch { - // Fresh overlay or no same-named stale symlink. - } - writeFileSync(pluginPath, source) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source, 'overlay') + writeOverlayOpenCodePluginAtomically(pluginPath, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the diff --git a/src/shared/opencode-plugin-atomic-write.test.ts b/src/shared/opencode-plugin-atomic-write.test.ts new file mode 100644 index 000000000000..597304a3ff85 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as retryOps from './windows-retry-file-operations' +import { + resolveCanonicalPluginWritePath, + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' + +type WriteFileSyncFn = typeof NodeFs.writeFileSync + +const { fsMock } = vi.hoisted(() => { + let realWrite: WriteFileSyncFn | undefined + return { + fsMock: { + writeFileSync: vi.fn(), + getRealWrite: (): WriteFileSyncFn | undefined => realWrite, + setRealWrite: (fn: WriteFileSyncFn): void => { + realWrite = fn + } + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsMock.setRealWrite(actual.writeFileSync) + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ) + return { + ...actual, + writeFileSync: fsMock.writeFileSync + } +}) + +afterEach(() => { + const realWrite = fsMock.getRealWrite() + if (realWrite) { + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + realWrite(...args) + ) + } + vi.restoreAllMocks() +}) + +describe('opencode-plugin-atomic-write', () => { + it('writes atomically via sibling temp file and never writes directly in place', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-atomic-write-')) + const pluginPath = join(testDir, 'plugins', 'orca-opencode-status.js') + const writtenPaths: string[] = [] + + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + fsMock.writeFileSync.mockImplementation( + ( + file: Parameters[0], + data: Parameters[1], + options: Parameters[2] + ) => { + writtenPaths.push(String(file)) + return realWrite(file, data, options) + } + ) + + try { + writeCanonicalOpenCodePluginAtomically(pluginPath, 'console.log("hello")') + expect(readFileSync(pluginPath, 'utf8')).toBe('console.log("hello")') + expect(writtenPaths).toHaveLength(1) + expect(writtenPaths[0]).not.toBe(pluginPath) + expect(writtenPaths[0]).toContain('.orca-opencode-status.js.') + expect(writtenPaths[0]).toContain('.tmp') + } finally { + rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('preserves symlink and updates underlying target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-canonical-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const realFile = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(realFile, 'initial content', 'utf8') + symlinkSync(realFile, linkFile) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(realpathSync.native(realFile)) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'updated content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(readFileSync(realFile, 'utf8')).toBe('updated content') + expect(readFileSync(linkFile, 'utf8')).toBe('updated content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves dangling symlink and creates destination target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-dangling-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const missingTarget = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + symlinkSync(missingTarget, linkFile) + expect(existsSync(missingTarget)).toBe(false) + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(missingTarget) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'dangling resolved content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('dangling resolved content') + expect(readFileSync(linkFile, 'utf8')).toBe('dangling resolved content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves existing file permissions when updating plugin', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-permissions-')) + const pluginPath = join(testDir, 'status.js') + + realWrite(pluginPath, 'old content', { encoding: 'utf8', mode: 0o600 }) + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content') + expect(readFileSync(pluginPath, 'utf8')).toBe('new content') + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('leaves existing target intact and cleans up temp file if rename fails', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-rename-fail-')) + const pluginPath = join(testDir, 'status.js') + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + realWrite(pluginPath, 'original content', 'utf8') + + vi.spyOn(retryOps, 'renameFileWithWindowsRetry').mockImplementation(() => { + throw new Error('EPERM: file locked') + }) + + expect(() => writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content')).toThrow( + 'EPERM: file locked' + ) + expect(readFileSync(pluginPath, 'utf8')).toBe('original content') + expect(readdirSync(testDir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('replaces symlink in overlay mode without mutating user target file', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-overlay-symlink-')) + const pluginsDir = join(testDir, 'overlay', 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const userPlugin = join(testDir, 'user-plugin.js') + const overlayPlugin = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(userPlugin, 'user original source', 'utf8') + symlinkSync(userPlugin, overlayPlugin) + + writeOverlayOpenCodePluginAtomically(overlayPlugin, 'orca status source') + + expect(lstatSync(overlayPlugin).isSymbolicLink()).toBe(false) + expect(lstatSync(overlayPlugin).isFile()).toBe(true) + expect(readFileSync(overlayPlugin, 'utf8')).toBe('orca status source') + expect(readFileSync(userPlugin, 'utf8')).toBe('user original source') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('creates missing directories automatically when writing plugin', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-nested-dir-')) + const deeplyNestedPlugin = join(testDir, 'nested', 'path', 'plugins', 'status.js') + + writeOverlayOpenCodePluginAtomically(deeplyNestedPlugin, 'content') + expect(existsSync(deeplyNestedPlugin)).toBe(true) + expect(readFileSync(deeplyNestedPlugin, 'utf8')).toBe('content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('resolves long dangling symlink chains and creates target without replacing intermediate links', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-long-symlinks-')) + const missingTarget = join(testDir, 'final-target.js') + let current = missingTarget + const links: string[] = [] + for (let i = 0; i < 15; i++) { + const nextLink = join(testDir, `link-${i}.js`) + symlinkSync(current, nextLink) + current = nextLink + links.push(nextLink) + } + + writeCanonicalOpenCodePluginAtomically(current, 'long chain content') + + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('long chain content') + for (const link of links) { + expect(lstatSync(link).isSymbolicLink()).toBe(true) + } + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('throws on symlink loop without replacing intermediate symlinks', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-loop-symlinks-')) + const linkA = join(testDir, 'link-a.js') + const linkB = join(testDir, 'link-b.js') + symlinkSync(linkB, linkA) + symlinkSync(linkA, linkB) + + expect(() => writeCanonicalOpenCodePluginAtomically(linkA, 'loop content')).toThrow( + /ELOOP|symbolic link/i + ) + expect(lstatSync(linkA).isSymbolicLink()).toBe(true) + expect(lstatSync(linkB).isSymbolicLink()).toBe(true) + + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts new file mode 100644 index 000000000000..cd90bb2f3db3 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { renameFileWithWindowsRetry } from './windows-retry-file-operations' + +function isEnoentError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +// Why: atomic rename on a dotfiles symlink replaces the link itself; resolving canonical target updates the real repo file. +export function resolveCanonicalPluginWritePath(pluginPath: string): string { + try { + const stat = lstatSync(pluginPath) + if (!stat.isSymbolicLink()) { + return pluginPath + } + } catch (error) { + if (isEnoentError(error)) { + return pluginPath + } + throw error + } + + // Why: realpathSync.native resolves canonical target when it exists; + // if target is missing (dangling symlink), follow readlinkSync chain so the + // target file is created at the intended destination and the symlink stays intact. + try { + return realpathSync.native(pluginPath) + } catch (error) { + if (!isEnoentError(error)) { + throw error + } + } + + const visited = new Set([pluginPath]) + let current = pluginPath + for (let depth = 0; depth < 40; depth++) { + try { + const link = readlinkSync(current) + current = resolve(dirname(current), link) + if (visited.has(current)) { + throw new Error(`Symbolic link loop detected resolving "${pluginPath}"`) + } + visited.add(current) + const nextStat = lstatSync(current) + if (!nextStat.isSymbolicLink()) { + return current + } + } catch (error) { + if (isEnoentError(error)) { + return current + } + throw error + } + } + throw new Error(`Too many levels of symbolic links resolving "${pluginPath}"`) +} + +// Why: write to sibling temp file and rename so concurrent reloads never observe a truncated or missing file. +function writeAtomicFile(targetPath: string, content: string): void { + const dir = dirname(targetPath) + mkdirSync(dir, { recursive: true }) + let existingMode: number | undefined + try { + existingMode = statSync(targetPath).mode + } catch { + // Target does not exist yet. + } + const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + renameFileWithWindowsRetry(tmpPath, targetPath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // Best effort cleanup. + } + } + } +} + +// Why: preserve dotfile symlinks at canonical paths by atomically replacing the real target file. +export function writeCanonicalOpenCodePluginAtomically(pluginPath: string, source: string): void { + const targetPath = resolveCanonicalPluginWritePath(pluginPath) + writeAtomicFile(targetPath, source) +} + +// Why: replace any mirrored symlink directly in the overlay without following it to the user's config. +export function writeOverlayOpenCodePluginAtomically(pluginPath: string, source: string): void { + writeAtomicFile(pluginPath, source) +} diff --git a/src/shared/opencode-tui-plugin-install.test.ts b/src/shared/opencode-tui-plugin-install.test.ts new file mode 100644 index 000000000000..18e08a4b5f55 --- /dev/null +++ b/src/shared/opencode-tui-plugin-install.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, it } from 'vitest' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin } from './opencode-tui-plugin-install' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function linkedEntry(): { plugins: string; entry: string; target: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + const plugins = join(root, 'plugins') + const entry = join(plugins, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const target = join(root, 'user-plugin.js') + mkdirSync(join(plugins, openCodeTuiPluginDirName('orca-status.js')), { recursive: true }) + writeFileSync(target, 'old') + symlinkSync(target, entry) + return { plugins, entry, target } +} + +it.skipIf(process.platform === 'win32')( + 'updates a canonical TUI symlink target and preserves the link', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'new') + expect(lstatSync(entry).isSymbolicLink()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('new') + } +) + +it.skipIf(process.platform === 'win32')( + 'detaches an overlay TUI link even when its user bytes match', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'old', 'overlay') + expect(lstatSync(entry).isFile()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('old') + } +) + +it('does not reload a current TUI plugin by changing its timestamp', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + const entry = join(root, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const before = statSync(entry).mtimeMs + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + expect(statSync(entry).mtimeMs).toBe(before) +}) diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index db05aa8834d3..cad825212a8c 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -1,6 +1,13 @@ -import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' import { join } from 'node:path' -import { isInstalledOpenCodePluginCurrent } from './opencode-installed-plugin' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from './opencode-installed-plugin' /** * Directory holding the TUI copy of a status plugin file. OpenCode 2 loads a @@ -19,18 +26,20 @@ export function openCodeTuiPluginDirName(pluginFileName: string): string { export function writeOpenCodeTuiPlugin( pluginsDir: string, pluginFileName: string, - source: string + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' ): void { const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) const entry = join(dir, 'tui.js') - if (isInstalledOpenCodePluginCurrent(entry, source)) { + const isCurrent = + ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent + if (isCurrent(entry, source)) { return } mkdirSync(dir, { recursive: true }) - try { - unlinkSync(entry) - } catch { - // First install, or nothing to replace. - } - writeFileSync(entry, source) + const write = + ownership === 'canonical' + ? writeCanonicalOpenCodePluginAtomically + : writeOverlayOpenCodePluginAtomically + write(entry, source) } diff --git a/src/shared/windows-retry-file-operations.ts b/src/shared/windows-retry-file-operations.ts new file mode 100644 index 000000000000..759bada16e9d --- /dev/null +++ b/src/shared/windows-retry-file-operations.ts @@ -0,0 +1,69 @@ +import { copyFileSync, renameSync } from 'node:fs' +import { rename } from 'node:fs/promises' +import { setTimeout } from 'node:timers/promises' + +// Why: on Windows, file replacement and backup-copy operations can fail with +// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) +// holds the target file open. A short retry avoids transient failures without +// masking real permission errors. Total backoff (~750ms) covers typical AV +// scan windows seen in issue #1507. +export function renameFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => renameSync(source, target)) +} + +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + +export function copyFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) +} + +function runFileOperationWithWindowsRetry(operation: () => void): void { + for (let attempt = 1; ; attempt++) { + try { + operation() + return + } catch (error) { + if (shouldRetryFileOperation(error, attempt)) { + sleepSync(attempt * 50) + continue + } + throw error + } + } +} + +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + +// Why: writeFileAtomically is a sync API called from sync paths, so the retry +// backoff must park the thread instead of burning CPU in a Date.now() loop. +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) +function sleepSync(ms: number): void { + Atomics.wait(sleepBuffer, 0, 0, ms) +} From 8bbb511e1abd1775a545a70642746a90faece9c5 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:08:14 -0700 Subject: [PATCH 21/93] fix(opencode): retain plugin permissions across restrictive umasks --- src/shared/opencode-plugin-atomic-write.ts | 6 ++- .../opencode-plugin-permissions.test.ts | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/shared/opencode-plugin-permissions.test.ts diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts index cd90bb2f3db3..1ef27089bd00 100644 --- a/src/shared/opencode-plugin-atomic-write.ts +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -71,13 +72,16 @@ function writeAtomicFile(targetPath: string, content: string): void { mkdirSync(dir, { recursive: true }) let existingMode: number | undefined try { - existingMode = statSync(targetPath).mode + existingMode = statSync(targetPath).mode & 0o777 } catch { // Target does not exist yet. } const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) try { writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + if (existingMode !== undefined) { + chmodSync(tmpPath, existingMode) + } renameFileWithWindowsRetry(tmpPath, targetPath) } finally { if (existsSync(tmpPath)) { diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts new file mode 100644 index 000000000000..213f062e1454 --- /dev/null +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from './child-process/run-process' +import { fileURLToPath } from 'node:url' +it.skipIf(process.platform === 'win32')( + 'retains existing permissions when the process umask is stricter', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) + const pluginPath = join(root, 'plugin.js') + const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + try { + const result = await runProcess({ + program: process.execPath, + args: [ + '--import', + 'tsx', + '--input-type=module', + '-e', + ` + import { chmodSync, statSync, writeFileSync } from 'node:fs'; + import { createRequire } from 'node:module'; + const { writeCanonicalOpenCodePluginAtomically } = createRequire(import.meta.url)(${JSON.stringify(modulePath)}); + const target = ${JSON.stringify(pluginPath)}; + writeFileSync(target, 'old'); + chmodSync(target, 0o664); + process.umask(0o027); + writeCanonicalOpenCodePluginAtomically(target, 'new'); + console.log(statSync(target).mode & 0o777); + ` + ], + timeoutMs: 10_000, + maxOutputBytes: 4_096 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe(String(0o664)) + expect(readFileSync(pluginPath, 'utf8')).toBe('new') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) From ee2de6a843361724bed799880a3932c357fb3dc2 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:48:34 -0700 Subject: [PATCH 22/93] test(opencode): resolve permission fixture from primary cwd --- src/shared/opencode-plugin-permissions.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts index 213f062e1454..bdf6f5fa3b91 100644 --- a/src/shared/opencode-plugin-permissions.test.ts +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -1,15 +1,14 @@ import { expect, it } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { runProcess } from './child-process/run-process' -import { fileURLToPath } from 'node:url' it.skipIf(process.platform === 'win32')( 'retains existing permissions when the process umask is stricter', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) const pluginPath = join(root, 'plugin.js') - const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + const modulePath = resolve(process.cwd(), 'src/shared/opencode-plugin-atomic-write.ts') try { const result = await runProcess({ program: process.execPath, From d10b63a06b329067a69a999afd128974a200bd0a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:00:27 -0700 Subject: [PATCH 23/93] feat(opencode): install startup prompt plugin independently of status hooks --- src/main/opencode/hook-service.ts | 18 +++- .../opencode-startup-prompt-installer.test.ts | 72 ++++++++++++++ .../opencode-startup-prompt-installer.ts | 12 +++ src/relay/opencode-overlay-mirror.ts | 40 ++++++++ .../opencode-startup-prompt-install.test.ts | 86 +++++++++++++++++ src/relay/plugin-overlay.ts | 96 +++++++++---------- src/relay/relay-agent-hook-runtime.ts | 7 ++ src/relay/wsl-install-plugins-handler.ts | 24 +++++ src/shared/opencode-startup-prompt-install.ts | 17 ++++ src/shared/opencode-tui-plugin-install.ts | 16 +++- 10 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index d7ce098bcec4..3d90f2f89d5b 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -28,7 +28,8 @@ import { } from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, - writeOpenCodeTuiPlugin + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory } from '../../shared/opencode-tui-plugin-install' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } @@ -54,6 +55,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -73,6 +75,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -92,6 +95,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -134,6 +138,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -260,6 +267,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -288,6 +296,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source, 'overlay') + return + } this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { writeOverlayOpenCodePluginAtomically(pluginPath, source) @@ -299,6 +311,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source) + return + } this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeCanonicalOpenCodePluginAtomically(pluginPath, source) diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 000000000000..628bc464335f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 000000000000..06c8d52e4c84 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,12 @@ +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 000000000000..0bd5e0b0f6c8 --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 000000000000..71cb373511e3 --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e0..08c9f772bed7 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 1dcda669f7d9..e7d37b6ed3e9 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -183,17 +183,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -210,8 +213,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea83..e66ff7d20a5e 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 000000000000..6427a9e98864 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index cad825212a8c..055aa3b7012e 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -29,7 +29,21 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent From ac48c772fad6220c387cca6fccc73440f3b29481 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:01:46 -0700 Subject: [PATCH 24/93] Restore inherited account environment and preserve cleanup retries --- .../pty-subprocess/spawn-environment.ts | 5 +- src/main/ipc/pty/ipc/spawn-options.ts | 2 + src/main/ipc/pty/runtime/spawn-options.ts | 2 + .../launch-environment.test.ts | 94 +++++++++++++++++++ .../launch-environment.ts | 15 +++ .../managed-data-accounts/service.test.ts | 17 ++++ src/main/managed-data-accounts/service.ts | 21 +++-- .../electron-serve-browser-process.test.ts | 1 + .../orcad/electron-serve-browser-process.ts | 3 + .../providers/local-pty-spawn-environment.ts | 5 +- src/relay/pty-handler.ts | 22 ++++- .../managed-data-account-environment.ts | 78 +++++++++++++++ 12 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 src/main/managed-data-accounts/launch-environment.test.ts create mode 100644 src/shared/managed-data-account-environment.ts diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index a063ffb3dd60..d28c61752a7f 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -1,4 +1,5 @@ import { getLegacyOpenCodeEnvKeysToDelete } from '../../opencode/legacy-shared-config-dir' +import { restoreManagedDataAccountEnvironment } from '../../../shared/managed-data-account-environment' import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' @@ -166,8 +167,10 @@ function removeInheritedDevAgentHookEndpoint( /** A persistent daemon's inherited environment cannot supply ownership for a new pane. */ export function createDaemonPtyEnvironment(opts: PtySubprocessOptions): Record { + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) const env: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), opts.env), + ...mergeGitConfigEnvProtocol(inheritedEnv, opts.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef16..411cb14f2871 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,4 +1,5 @@ import { getAppEnvironment } from '../../../../shared/app-environment' +import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' @@ -50,6 +51,7 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.spawnEnv) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..56697f94badb 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,4 +1,5 @@ import { getAppEnvironment } from '../../../../shared/app-environment' +import { getInheritedManagedDataAccountEnvKeysToDelete } from '../../../../shared/managed-data-account-environment' import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' @@ -79,6 +80,7 @@ export async function buildRuntimePtySpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + ctx.isDaemonHostSpawn ? getInheritedManagedDataAccountEnvKeysToDelete(ctx.env) : [], // The daemon must judge its own inherited value; main may have a different config. !args.connectionId && !ctx.isDaemonHostSpawn ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) diff --git a/src/main/managed-data-accounts/launch-environment.test.ts b/src/main/managed-data-accounts/launch-environment.test.ts new file mode 100644 index 000000000000..85e5a669626b --- /dev/null +++ b/src/main/managed-data-accounts/launch-environment.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + captureManagedDataAccountOriginalEnvironment, + restoreManagedDataAccountEnvironment, + getInheritedManagedDataAccountEnvKeysToDelete +} from '../../shared/managed-data-account-environment' +import { createDaemonPtyEnvironment } from '../daemon/pty-subprocess/spawn-environment' + +const selected = vi.hoisted(() => { + const value: Record = {} + return { value } +}) +vi.mock('./service', () => ({ + getManagedDataAccountService: () => ({ launchEnvironment: () => selected.value }) +})) +import { applyManagedDataAccountEnvironment } from './launch-environment' + +function inheritedProfile(): Record { + const env = { + XDG_DATA_HOME: join(tmpdir(), 'user-data'), + OPENCODE_DB: 'user.db', + OPENCODE_AUTH_CONTENT: 'user-config' + } + captureManagedDataAccountOriginalEnvironment(env) + return { + ...env, + XDG_DATA_HOME: join(tmpdir(), 'old-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'old-account', 'state'), + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '', + ORCA_DATA_ACCOUNT_DATA_HOME: join(tmpdir(), 'old-account', 'data'), + ORCA_DATA_ACCOUNT_STATE_HOME: join(tmpdir(), 'old-account', 'state'), + ORCA_DATA_ACCOUNT_PROVIDER: 'opencode' + } +} + +afterEach(() => { + vi.unstubAllEnvs() + selected.value = {} +}) + +describe('managed account inherited environment', () => { + it.each([ + { launchAgent: 'opencode' as const }, + { launchAgent: 'claude' as const }, + { launchAgent: 'opencode' as const, isWsl: true } + ])('restores user defaults before eligibility and system selection: %o', (options) => { + const env = inheritedProfile() + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value) + } + applyManagedDataAccountEnvironment(env, options) + expect(env.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(env.XDG_STATE_HOME).toBeUndefined() + expect(env.OPENCODE_DB).toBe('user.db') + expect(env.OPENCODE_AUTH_CONTENT).toBe('user-config') + expect(Object.keys(env).some((key) => key.startsWith('ORCA_DATA_ACCOUNT'))).toBe(false) + const final = createDaemonPtyEnvironment({ sessionId: 'test', cols: 80, rows: 24, env }) + expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(final.XDG_STATE_HOME).toBeUndefined() + expect(final.ORCA_DATA_ACCOUNT_DATA_HOME).toBeUndefined() + expect(getInheritedManagedDataAccountEnvKeysToDelete(env)).toContain('XDG_STATE_HOME') + }) + + it('keeps a fresh selection after the daemon restores its own parent profile', () => { + for (const [key, value] of Object.entries(inheritedProfile())) { + vi.stubEnv(key, value) + } + selected.value = { + XDG_DATA_HOME: join(tmpdir(), 'new-account', 'data'), + XDG_STATE_HOME: join(tmpdir(), 'new-account', 'state'), + OPENCODE_DB: 'opencode.db', + OPENCODE_AUTH_CONTENT: '' + } + const env: Record = {} + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const final = createDaemonPtyEnvironment({ sessionId: 'test', cols: 80, rows: 24, env }) + expect(final.XDG_DATA_HOME).toBe(selected.value.XDG_DATA_HOME) + restoreManagedDataAccountEnvironment(final) + expect(final.XDG_DATA_HOME).toBe(join(tmpdir(), 'user-data')) + expect(final.OPENCODE_DB).toBe('user.db') + }) + + it('scrubs client-owned profile paths without restoring desktop paths on a relay', () => { + const env = inheritedProfile() + restoreManagedDataAccountEnvironment(env, false) + expect(env.XDG_DATA_HOME).toBeUndefined() + expect(env.XDG_STATE_HOME).toBeUndefined() + expect(env.OPENCODE_DB).toBeUndefined() + expect(Object.keys(env).some((key) => key.startsWith('ORCA_DATA_ACCOUNT'))).toBe(false) + }) +}) diff --git a/src/main/managed-data-accounts/launch-environment.ts b/src/main/managed-data-accounts/launch-environment.ts index 382addedef3d..8546b001cf96 100644 --- a/src/main/managed-data-accounts/launch-environment.ts +++ b/src/main/managed-data-accounts/launch-environment.ts @@ -4,11 +4,25 @@ import { } from '../../shared/command-token-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { getManagedDataAccountService } from './service' +import { + captureManagedDataAccountOriginalEnvironment, + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + restoreManagedDataAccountEnvironment +} from '../../shared/managed-data-account-environment' export function applyManagedDataAccountEnvironment( environment: Record, options: { launchAgent?: TuiAgent; launchCommand?: string; isWsl?: boolean } ): void { + restoreManagedDataAccountEnvironment(environment) + const inherited = { ...process.env } + restoreManagedDataAccountEnvironment(inherited) + for (const key of MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS) { + const value = inherited[key] + if (environment[key] === undefined && value !== undefined) { + environment[key] = value + } + } if (options.isWsl) { return } @@ -27,6 +41,7 @@ export function applyManagedDataAccountEnvironment( if (!selected.XDG_DATA_HOME) { return } + captureManagedDataAccountOriginalEnvironment(environment) Object.assign(environment, selected) environment.ORCA_DATA_ACCOUNT_DATA_HOME = selected.XDG_DATA_HOME environment.ORCA_DATA_ACCOUNT_STATE_HOME = selected.XDG_STATE_HOME diff --git a/src/main/managed-data-accounts/service.test.ts b/src/main/managed-data-accounts/service.test.ts index 76cc162ea4a2..cd00f67f9111 100644 --- a/src/main/managed-data-accounts/service.test.ts +++ b/src/main/managed-data-accounts/service.test.ts @@ -42,6 +42,23 @@ function openCodeSource(sessionTable = 'session'): void { } describe('managed data accounts', () => { + it('retains selected account metadata after locked cleanup and permits retry', async () => { + let locked = true + service = new ManagedDataAccountService(join(root, 'managed'), (directory) => { + if (locked) { + throw new Error('file locked') + } + rmSync(directory, { recursive: true, force: true }) + }) + const before = await service.add('devin', source, 'Work') + await expect(service.remove('devin', before.accounts[0].id)).rejects.toThrow('file locked') + expect(service.list('devin')).toEqual(before) + expect(service.launchEnvironment('devin').XDG_DATA_HOME).toBeTruthy() + locked = false + await service.remove('devin', before.accounts[0].id) + expect(service.list('devin')).toEqual({ accounts: [], activeAccountId: null }) + }) + it('registers private Devin credentials, exposes summaries, and removes only its profile', async () => { const state = await service.add('devin', source, 'Work') const id = state.accounts[0].id diff --git a/src/main/managed-data-accounts/service.ts b/src/main/managed-data-accounts/service.ts index 0c053333f82f..2960cb935628 100644 --- a/src/main/managed-data-accounts/service.ts +++ b/src/main/managed-data-accounts/service.ts @@ -28,7 +28,11 @@ export class ManagedDataAccountService { private pending: Promise = Promise.resolve() private readonly listeners = new Set<() => void>() - constructor(private readonly root: string) {} + constructor( + private readonly root: string, + private readonly removeDirectory: (directory: string) => void = (directory) => + rmSync(directory, { recursive: true, force: true }) + ) {} list(provider: ManagedDataAccountProvider): ManagedDataAccountsState { const path = join(this.root, provider, 'accounts.json') @@ -89,15 +93,18 @@ export class ManagedDataAccountService { ): Promise { return this.mutate(async () => { const state = this.list(provider) - this.requireAccount(provider, accountId) - const result = this.persist(provider, { + if (!state.accounts.some((account) => account.id === accountId)) { + throw new Error('Managed account not found.') + } + const directory = join(this.root, provider, accountId) + if (existsSync(directory)) { + this.assertOwned(directory) + } + this.removeDirectory(directory) + return this.persist(provider, { accounts: state.accounts.filter((account) => account.id !== accountId), activeAccountId: state.activeAccountId === accountId ? null : state.activeAccountId }) - const directory = join(this.root, provider, accountId) - this.assertOwned(directory) - rmSync(directory, { recursive: true, force: true }) - return result }) } diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index e9e95a8fbd5a..2dc9202fdee2 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -195,6 +195,7 @@ describe('ElectronServeBrowserProcess start-up', () => { for (const key of ['ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA', 'ORCA_USER_DATA_PATH']) { expect(spec.env).not.toHaveProperty(key) } + expect(args).toEqual(expect.arrayContaining(['--password-store=basic', '--use-mock-keychain'])) expect(processHandle.isAvailable()).toBe(true) }) diff --git a/src/main/orcad/electron-serve-browser-process.ts b/src/main/orcad/electron-serve-browser-process.ts index 733f9f86af32..69cc41ee7d31 100644 --- a/src/main/orcad/electron-serve-browser-process.ts +++ b/src/main/orcad/electron-serve-browser-process.ts @@ -115,6 +115,9 @@ export class ElectronServeBrowserProcess { String(port), '--serve-json', '--serve-no-pairing', + ...(process.env.ORCA_E2E_USER_DATA_DIR || process.env.ORCA_E2E_HOME_DIR + ? ['--password-store=basic', '--use-mock-keychain'] + : []), `--user-data-dir=${userDataPath}` ], env: electronServeEnvironment() diff --git a/src/main/providers/local-pty-spawn-environment.ts b/src/main/providers/local-pty-spawn-environment.ts index 645fcacb2d0d..2d4694ba5eaf 100644 --- a/src/main/providers/local-pty-spawn-environment.ts +++ b/src/main/providers/local-pty-spawn-environment.ts @@ -1,4 +1,5 @@ import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' +import { restoreManagedDataAccountEnvironment } from '../../shared/managed-data-account-environment' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -22,8 +23,10 @@ export function buildLocalPtySpawnEnvironment(args: { plan: LocalPtyLaunchPlan }): Record | Promise> { const { id, spawn, getOptions, plan } = args + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) const spawnEnv: Record = { - ...mergeGitConfigEnvProtocol(stripInheritedBuildModeEnv(process.env), spawn.env), + ...mergeGitConfigEnvProtocol(inheritedEnv, spawn.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fbad4a212a0d..46acda30720e 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,5 +1,6 @@ /* oxlint-disable max-lines */ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchronized-output-scan' +import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-account-environment' import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' @@ -822,9 +823,13 @@ export class PtyHandler { }, envToDelete: readonly string[] = [] ): Promise> { - const baseEnv = mergeGitConfigEnvProtocol( + const inheritedEnv = stripInheritedBuildModeEnv(process.env) + restoreManagedDataAccountEnvironment(inheritedEnv) + const explicitEnv = { ...rendererEnv } + restoreManagedDataAccountEnvironment(explicitEnv, false) + const mergedEnv = mergeGitConfigEnvProtocol( { - ...stripInheritedBuildModeEnv(process.env), + ...inheritedEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'Orca', @@ -832,8 +837,11 @@ export class PtyHandler { rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev', FORCE_HYPERLINK: '1' }, - rendererEnv - ) as Record + explicitEnv + ) + const baseEnv: Record = Object.fromEntries( + Object.entries(mergedEnv).filter(([, value]) => typeof value === 'string') + ) const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { @@ -844,7 +852,11 @@ export class PtyHandler { ) } } - const result = mergeGitConfigEnvProtocol(baseEnv, augmented) as Record + const result: Record = Object.fromEntries( + Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( + ([, value]) => typeof value === 'string' + ) + ) result[ORCA_IMAGE_PROTOCOL_ENV] = ORCA_IMAGE_PROTOCOL_VALUE // Why: an older client may not ask a newly upgraded relay to delete inherited shim state. stripLegacyTerminalShimEnv(result, process.platform) diff --git a/src/shared/managed-data-account-environment.ts b/src/shared/managed-data-account-environment.ts new file mode 100644 index 000000000000..8bfeca0af8da --- /dev/null +++ b/src/shared/managed-data-account-environment.ts @@ -0,0 +1,78 @@ +import { z } from 'zod' + +const originalEnvironment = z.object({ + XDG_DATA_HOME: z.string().nullable(), + XDG_STATE_HOME: z.string().nullable(), + OPENCODE_AUTH_CONTENT: z.string().nullable(), + OPENCODE_DB: z.string().nullable() +}) +const ORIGINAL_ENV = 'ORCA_DATA_ACCOUNT_ORIGINAL_ENV' +export const MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS = [ + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'OPENCODE_AUTH_CONTENT', + 'OPENCODE_DB' +] as const + +export function getInheritedManagedDataAccountEnvKeysToDelete( + environment: Record | undefined +): string[] { + return [ + ...MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS, + 'ORCA_DATA_ACCOUNT_DATA_HOME', + 'ORCA_DATA_ACCOUNT_STATE_HOME', + 'ORCA_DATA_ACCOUNT_PROVIDER', + ORIGINAL_ENV + ].filter((key) => environment?.[key] === undefined) +} + +export function captureManagedDataAccountOriginalEnvironment( + environment: Record +): void { + environment[ORIGINAL_ENV] = JSON.stringify( + Object.fromEntries( + MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS.map((key) => [key, environment[key] ?? null]) + ) + ) +} + +export function restoreManagedDataAccountEnvironment( + environment: Record, + restoreOriginal = true +): void { + let original: z.infer | undefined + try { + const parsed = originalEnvironment.safeParse(JSON.parse(environment[ORIGINAL_ENV] ?? 'null')) + if (restoreOriginal && parsed.success) { + original = parsed.data + } + } catch { + // Older panes have no baseline snapshot; strip only their owned overrides. + } + function restore( + key: (typeof MANAGED_DATA_ACCOUNT_BASELINE_ENV_KEYS)[number], + ownedValue: string | undefined + ): void { + if (ownedValue === undefined || environment[key] !== ownedValue) { + return + } + const value = original?.[key] + if (typeof value === 'string') { + environment[key] = value + } else { + delete environment[key] + } + } + if (environment.ORCA_DATA_ACCOUNT_DATA_HOME) { + restore('XDG_DATA_HOME', environment.ORCA_DATA_ACCOUNT_DATA_HOME) + restore('XDG_STATE_HOME', environment.ORCA_DATA_ACCOUNT_STATE_HOME) + if (environment.ORCA_DATA_ACCOUNT_PROVIDER === 'opencode') { + restore('OPENCODE_AUTH_CONTENT', '') + restore('OPENCODE_DB', 'opencode.db') + } + } + delete environment.ORCA_DATA_ACCOUNT_DATA_HOME + delete environment.ORCA_DATA_ACCOUNT_STATE_HOME + delete environment.ORCA_DATA_ACCOUNT_PROVIDER + delete environment[ORIGINAL_ENV] +} From 1b024b911bcfc695244c1bb75611ea90cfdb4a33 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:52:31 -0700 Subject: [PATCH 25/93] fix(opencode): select plugin loader for the launched host binary --- src/main/ipc/pty/ipc/spawn-options.ts | 12 ++++ src/main/ipc/pty/runtime/spawn-options.ts | 13 ++++ src/main/opencode/opencode-pty-launch.test.ts | 72 +++++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 33 +++++++++ src/main/providers/pty-spawn-result.ts | 3 + src/relay/pty-handler.ts | 29 +++++++- 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 411cb14f2871..07bd7e3a1643 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -28,6 +28,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -66,6 +67,17 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 56697f94badb..77a6b9bb0ad8 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -35,6 +35,7 @@ import { } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -104,6 +105,18 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 000000000000..2a1cfb4e7cd4 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) + +describe('execution-host OpenCode launch preparation', () => { + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 000000000000..cfd0f815f143 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,33 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: { ...process.env, ...env } + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b66..4eb5dc45a60d 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 46acda30720e..dd64baf2be12 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -4,6 +4,9 @@ import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-acc import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -217,6 +220,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -276,6 +280,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -1917,6 +1922,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1945,6 +1953,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -1991,6 +2000,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { + spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi + if (isRelayWslShell(shell)) { + addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + } await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2091,6 +2116,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2169,7 +2195,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } From a6516bbd9ba411167e4ba0fe08d4ddb36c509df8 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:05:36 -0700 Subject: [PATCH 26/93] fix(opencode): match WSL probe cwd and declared guest environment --- .../opencode-launch-capabilities.test.ts | 55 +++++++++++++++++-- .../opencode/opencode-launch-capabilities.ts | 30 +++++++--- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 81eb123cbce4..6e4a09130631 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -66,31 +66,76 @@ describe('OpenCode execution-host launch capability probe', () => { expect(mocks.probe).not.toHaveBeenCalled() }) - it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } await probeOpenCodeLaunchCapabilities({ command: 'opencode --standalone', - env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, - wsl: { distro: 'Ubuntu' }, + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, hostIdentity: 'host-a' }) expect(mocks.resolve).not.toHaveBeenCalled() const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } expect(options).toEqual( expect.objectContaining({ executablePath: 'opencode', hostIdentity: 'host-a:wsl:Ubuntu', - env: { OPENCODE_CONFIG_DIR: '/guest/config' } + cwd: '/home/user/repo', + env: guestEnv }) ) await options.execute() expect(mocks.wsl).toHaveBeenCalledWith({ distro: 'Ubuntu', loginPath: 'preferred', + cwd: '/home/user/repo', program: 'opencode', args: ['--version'], - env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + env: guestEnv, timeoutMs: 5000, maxOutputBytes: 4096 }) }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: {} }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) }) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 1d63cf0ed446..86ade3f8af5e 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -1,3 +1,4 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getFirstCommandToken, getCommandTokenPathBasename @@ -41,25 +42,36 @@ export async function probeOpenCodeLaunchCapabilities(options: { } if (options.wsl) { const guestEnv: Record = {} - for (const key of [ - 'OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_CONFIG_DIR', - 'OPENCODE_DISABLE_AUTOUPDATE' - ]) { - const value = options.env[key] - if (value !== undefined) { - guestEnv[key] = value + const wslEnv = options.env.WSLENV + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } } } - const distro = options.wsl.distro + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro return probeOpenCodeCliVersion({ executablePath: executable, env: guestEnv, + cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, execute: () => runWslProcess({ distro, loginPath: 'preferred', + cwd, program: executable, args: ['--version'], env: guestEnv, From fb2b8854d359bc5064d7a3ff9f4000303df51d19 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:39:15 -0700 Subject: [PATCH 27/93] fix(opencode): preserve launch environment deletion boundaries --- src/main/ipc/pty/ipc/spawn-options.ts | 1 + src/main/ipc/pty/runtime/spawn-options.ts | 1 + .../opencode-launch-capabilities.test.ts | 20 ++++ .../opencode/opencode-launch-capabilities.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 92 ++++++++++++++++++- src/main/opencode/opencode-pty-launch.ts | 16 +++- 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 07bd7e3a1643..ebe4fee9e285 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -71,6 +71,7 @@ export async function buildPtyIpcSpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 77a6b9bb0ad8..bada4283edfd 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -109,6 +109,7 @@ export async function buildRuntimePtySpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 6e4a09130631..9d15cefe80f2 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -13,9 +13,29 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) beforeEach(() => { vi.clearAllMocks() mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) }) describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) it('recognizes quoted executables and preserves explicit run commands', () => { expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 86ade3f8af5e..17a2a570872f 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -67,8 +67,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { env: guestEnv, cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, - execute: () => - runWslProcess({ + execute: async () => { + const result = await runWslProcess({ distro, loginPath: 'preferred', cwd, @@ -78,6 +78,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { timeoutMs: 5_000, maxOutputBytes: 4_096 }) + return result.environmentResolved ? result : { ...result, code: null } + } }) } const executablePath = options.resolveExecutable diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 2a1cfb4e7cd4..f7813ddc969e 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,13 +1,90 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + it.each(['1.1.23', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { @@ -21,6 +98,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode --prompt test', agent: 'opencode', env, + envToDelete: [], cwd: '/repo', isFreshLaunch: true }) @@ -39,7 +117,12 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) }) @@ -50,6 +133,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode', agent: 'opencode', env, + envToDelete: [], isFreshLaunch: true, wsl: { distro: 'Ubuntu' } }) @@ -64,7 +148,9 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index cfd0f815f143..5aa5adab0726 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,11 +1,13 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined agent?: TuiAgent env: Record | undefined + envToDelete: string[] cwd?: string connectionId?: string | null isFreshLaunch: boolean @@ -15,17 +17,29 @@ export async function prepareOpenCodePtyLaunch(options: { if (env) { delete env.ORCA_OPENCODE_PLUGIN_API } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } if (options.connectionId || !options.isFreshLaunch) { return env } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) const capabilities = await probeOpenCodeLaunchCapabilities({ ...options, - env: { ...process.env, ...env } + env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { return env } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } From 1749bfd20e2fe08166a821a3e5a21e66a032fa77 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:36:27 -0700 Subject: [PATCH 28/93] wip(opencode): authorize native startup prompt intent at execution owner --- .../server-startup-prompt-control.test.ts | 58 +++++ .../agent-hooks/server/server-lifecycle.ts | 31 ++- .../agent-hooks/server/server-listeners.ts | 5 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/opencode/opencode-pty-launch.test.ts | 90 +++++++- src/main/opencode/opencode-pty-launch.ts | 75 ++++++- .../opencode-startup-prompt-claims.test.ts | 49 +++++ .../opencode-startup-prompt-claims.ts | 70 ++++++ .../opencode/opencode-startup-prompt-owner.ts | 92 ++++++++ .../opencode-startup-prompt-source.test.ts | 207 ++++++++++++++++++ .../opencode-startup-prompt-source.ts | 111 ++++++++++ src/main/orcad/orcad-entry.ts | 8 +- src/main/runtime/orca-runtime-runtime-id.ts | 14 ++ src/main/runtime/terminal-run-facts.test.ts | 15 ++ src/main/runtime/terminal-run-facts.ts | 41 +++- src/main/startup/main-process-pty-startup.ts | 4 +- src/relay/pty-handler.ts | 17 ++ src/shared/opencode-startup-prompt.test.ts | 52 +++++ src/shared/opencode-startup-prompt.ts | 39 ++++ src/shared/tui-agent-startup.ts | 3 +- 25 files changed, 980 insertions(+), 26 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 000000000000..ebc96e63dea3 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' + +describe('startup prompt control with status hooks disabled', () => { + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e6e1bf80061d..49fb90321139 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -15,6 +15,7 @@ import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hoo import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { /** Start the loopback listener after hydration and spool replay have settled. */ @@ -22,10 +23,12 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,7 +40,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { + if (this.statusHooksEnabled && !this.ownerStateInitialized) { // Why: hydrate before binding the listener so an early hook POST runs against a populated map. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() @@ -84,6 +87,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -147,6 +160,11 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -187,7 +205,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -200,12 +220,17 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + if (this.statusHooksEnabled) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 961d3a3e9c87..ec03eefd98bc 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,6 +25,11 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index e5b9e4c13630..dfb35cae6be5 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 49bb8ade3ac9..2889ba293d97 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,6 +92,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11b..24df59c1201c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index bada4283edfd..df69daed3ae5 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -105,7 +105,7 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -117,6 +117,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969e..27f327a4beac 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,6 +34,14 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { endpointFilePath: '/private/endpoint.env' } +})) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) @@ -35,13 +49,75 @@ beforeEach(() => probe.mockReset()) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +143,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +170,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +193,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +205,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +224,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab0726..314bec8770ed 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,5 +1,25 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' @@ -12,17 +32,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +68,47 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 000000000000..1dae2785a51b --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 000000000000..9dabf9ce426e --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,70 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + readOwner: () => TerminalRunFacts | null + cleanup: () => void + expiry: ReturnType +} + +/** A one-use launch authorization; the execution owner supplies the process facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { digest, readOwner, expiresAt: now + 20000, cleanup, expiry }) + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + this.cancel(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + return false + } + const owner = pending.readOwner() + return owner?.freshSpawn === true && owner.firstUserInputAt === null + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 000000000000..72068fc81d40 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,92 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +const claims = new OpenCodeStartupPromptClaims() + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OrcaRuntimeService } + result: PtySpawnResult + provider: IPtyProvider + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OrcaRuntimeService | undefined + provider: IPtyProvider + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + return + } + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + let unsubscribe = () => {} + if ( + claims.register( + nonce, + digest, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 000000000000..09ea92d80015 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,207 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and dispatches only until OpenCode clears the exact draft', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 000000000000..0a140a9ad079 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,111 @@ +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint) { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + const response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return false; + return (await response.json()).allowed === true; +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => settle(); + // OpenCode clears the editor before awaiting session creation; never retry a restored failure. + const changed = () => { if (seen && editor.plainText !== (populated ? prompt : "")) settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + ({ createHash } = await import("node:crypto")); + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== (populated ? prompt : "")) return settle(); + seen = true; + const agents = ctx.data.location.agent.list(ctx.location); + const models = ctx.data.location.model.list(ctx.location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + if (!claimed) { + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint); + claiming = false; + if (!allowed) return settle(); + claimed = true; + } + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + if (!populated) { + populated = true; + editor.insertText(prompt); + if (memory.settled || !matches(editor)) return settle(); + } + ctx.keymap.dispatch("prompt.submit"); + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 2aa611658b79..9178dfbf7625 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -200,9 +200,11 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dbdec24d0fc0..c856b91e3852 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -244,6 +244,20 @@ export class OrcaRuntimeWithRuntimeId { readonly terminalRunFacts = new TerminalRunFactsRegister() + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if ( + !pty || + pty.incarnationId !== incarnationId || + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + /** Both spawn-commit funnels report each committed process here, once. */ noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27c..1966a4059490 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4c..b1c029a767c1 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 648db87a3b61..5edfa51b110a 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -164,9 +164,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -175,6 +172,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index dd64baf2be12..eda7157d4749 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -29,6 +29,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -2001,6 +2008,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 000000000000..e4aab26d0650 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain('run --prompt') + }) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 000000000000..1dbe9c2719d8 --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c41..ce8974a8cc33 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) } } From 6115e13af046b977951126ebafd527fca02b1742 Mon Sep 17 00:00:00 2001 From: Ahmed Nagy Date: Fri, 2 Oct 2026 00:50:32 -0700 Subject: [PATCH 29/93] fix(opencode): atomically replace status plugin entrypoints --- src/main/codex-accounts/fs-utils.ts | 77 +---- src/main/opencode/hook-service.ts | 30 +- src/relay/opencode-canonical-config.ts | 10 +- src/relay/plugin-overlay.test.ts | 4 +- src/relay/plugin-overlay.ts | 11 +- .../opencode-plugin-atomic-write.test.ts | 279 ++++++++++++++++++ src/shared/opencode-plugin-atomic-write.ts | 102 +++++++ .../opencode-tui-plugin-install.test.ts | 59 ++++ src/shared/opencode-tui-plugin-install.ts | 29 +- src/shared/windows-retry-file-operations.ts | 69 +++++ 10 files changed, 560 insertions(+), 110 deletions(-) create mode 100644 src/shared/opencode-plugin-atomic-write.test.ts create mode 100644 src/shared/opencode-plugin-atomic-write.ts create mode 100644 src/shared/opencode-tui-plugin-install.test.ts create mode 100644 src/shared/windows-retry-file-operations.ts diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 295dfac9b66b..0f96932ecb85 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,10 +1,15 @@ import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { rename } from 'node:fs/promises' +import { existsSync, linkSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' +import { + copyFileWithWindowsRetry, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from '../../shared/windows-retry-file-operations' + +export { copyFileWithWindowsRetry, renameFileWithWindowsRetry, renameFileWithWindowsRetryAsync } export function writeFileAtomically( targetPath: string, @@ -193,69 +198,3 @@ export function publishFileWithoutOverwrite(sourcePath: string, targetPath: stri throw error } } - -// Why: on Windows, file replacement and backup-copy operations can fail with -// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) -// holds the target file open. A short retry avoids transient failures without -// masking real permission errors. Total backoff (~750ms) covers typical AV -// scan windows seen in issue #1507. -export function renameFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => renameSync(source, target)) -} - -export async function renameFileWithWindowsRetryAsync( - source: string, - target: string, - isCurrent: () => boolean = () => true -): Promise { - for (let attempt = 1; ; attempt++) { - if (!isCurrent()) { - return false - } - try { - await rename(source, target) - return true - } catch (error) { - if (!shouldRetryFileOperation(error, attempt)) { - throw error - } - await setTimeout(attempt * 50) - } - } -} - -export function copyFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) -} - -function runFileOperationWithWindowsRetry(operation: () => void): void { - for (let attempt = 1; ; attempt++) { - try { - operation() - return - } catch (error) { - if (shouldRetryFileOperation(error, attempt)) { - sleepSync(attempt * 50) - continue - } - throw error - } - } -} - -function shouldRetryFileOperation(error: unknown, attempt: number): boolean { - return ( - process.platform === 'win32' && - attempt < 6 && - error instanceof Error && - 'code' in error && - (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') - ) -} - -// Why: writeFileAtomically is a sync API called from sync paths, so the retry -// backoff must park the thread instead of burning CPU in a Date.now() loop. -const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) -function sleepSync(ms: number): void { - Atomics.wait(sleepBuffer, 0, 0, ms) -} diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 88007d05c88b..d7ce098bcec4 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,4 +1,3 @@ -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -8,7 +7,6 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -35,6 +33,11 @@ import { export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' + const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' @@ -139,7 +142,7 @@ export class OpenCodeHookService { // Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane. this.writeTuiPlugin(pluginsDir, source) if (installed !== source) { - writeFileAtomically(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -279,20 +282,15 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. + // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) + this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. - } - writeFileSync(pluginPath, source) + writeOverlayOpenCodePluginAtomically(pluginPath, source) } } @@ -303,13 +301,17 @@ export class OpenCodeHookService { const source = this.pluginSource() this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } - private writeTuiPlugin(pluginsDir: string, source: string): void { + private writeTuiPlugin( + pluginsDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' + ): void { if (this.installsTuiPlugin) { - writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source, ownership) } } } diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 8cdfa7d5536d..21ed93a3d769 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' +import { writeCanonicalOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { writeOpenCodeTuiPlugin } from '../shared/opencode-tui-plugin-install' const RELAY_HOOKS_DIR = '.orca-relay' @@ -26,12 +27,7 @@ export function installOpenCodePluginInCanonicalConfig( mkdirSync(join(configDir, 'plugins'), { recursive: true }) writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. - } - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } return true } catch (err) { diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 9129e202287b..6694627e9aeb 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -137,9 +137,9 @@ describe('PluginOverlayManager', () => { manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) - expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') - expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin, next release') } ) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 35455d06859e..6d78918c46e0 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -24,9 +24,9 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' +import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' @@ -214,13 +214,8 @@ export class PluginOverlayManager { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, pluginFileName) - writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) - try { - unlinkSync(pluginPath) - } catch { - // Fresh overlay or no same-named stale symlink. - } - writeFileSync(pluginPath, source) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source, 'overlay') + writeOverlayOpenCodePluginAtomically(pluginPath, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the diff --git a/src/shared/opencode-plugin-atomic-write.test.ts b/src/shared/opencode-plugin-atomic-write.test.ts new file mode 100644 index 000000000000..597304a3ff85 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as retryOps from './windows-retry-file-operations' +import { + resolveCanonicalPluginWritePath, + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' + +type WriteFileSyncFn = typeof NodeFs.writeFileSync + +const { fsMock } = vi.hoisted(() => { + let realWrite: WriteFileSyncFn | undefined + return { + fsMock: { + writeFileSync: vi.fn(), + getRealWrite: (): WriteFileSyncFn | undefined => realWrite, + setRealWrite: (fn: WriteFileSyncFn): void => { + realWrite = fn + } + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsMock.setRealWrite(actual.writeFileSync) + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ) + return { + ...actual, + writeFileSync: fsMock.writeFileSync + } +}) + +afterEach(() => { + const realWrite = fsMock.getRealWrite() + if (realWrite) { + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + realWrite(...args) + ) + } + vi.restoreAllMocks() +}) + +describe('opencode-plugin-atomic-write', () => { + it('writes atomically via sibling temp file and never writes directly in place', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-atomic-write-')) + const pluginPath = join(testDir, 'plugins', 'orca-opencode-status.js') + const writtenPaths: string[] = [] + + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + fsMock.writeFileSync.mockImplementation( + ( + file: Parameters[0], + data: Parameters[1], + options: Parameters[2] + ) => { + writtenPaths.push(String(file)) + return realWrite(file, data, options) + } + ) + + try { + writeCanonicalOpenCodePluginAtomically(pluginPath, 'console.log("hello")') + expect(readFileSync(pluginPath, 'utf8')).toBe('console.log("hello")') + expect(writtenPaths).toHaveLength(1) + expect(writtenPaths[0]).not.toBe(pluginPath) + expect(writtenPaths[0]).toContain('.orca-opencode-status.js.') + expect(writtenPaths[0]).toContain('.tmp') + } finally { + rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('preserves symlink and updates underlying target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-canonical-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const realFile = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(realFile, 'initial content', 'utf8') + symlinkSync(realFile, linkFile) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(realpathSync.native(realFile)) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'updated content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(readFileSync(realFile, 'utf8')).toBe('updated content') + expect(readFileSync(linkFile, 'utf8')).toBe('updated content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves dangling symlink and creates destination target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-dangling-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const missingTarget = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + symlinkSync(missingTarget, linkFile) + expect(existsSync(missingTarget)).toBe(false) + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(missingTarget) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'dangling resolved content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('dangling resolved content') + expect(readFileSync(linkFile, 'utf8')).toBe('dangling resolved content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves existing file permissions when updating plugin', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-permissions-')) + const pluginPath = join(testDir, 'status.js') + + realWrite(pluginPath, 'old content', { encoding: 'utf8', mode: 0o600 }) + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content') + expect(readFileSync(pluginPath, 'utf8')).toBe('new content') + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('leaves existing target intact and cleans up temp file if rename fails', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-rename-fail-')) + const pluginPath = join(testDir, 'status.js') + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + realWrite(pluginPath, 'original content', 'utf8') + + vi.spyOn(retryOps, 'renameFileWithWindowsRetry').mockImplementation(() => { + throw new Error('EPERM: file locked') + }) + + expect(() => writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content')).toThrow( + 'EPERM: file locked' + ) + expect(readFileSync(pluginPath, 'utf8')).toBe('original content') + expect(readdirSync(testDir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('replaces symlink in overlay mode without mutating user target file', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-overlay-symlink-')) + const pluginsDir = join(testDir, 'overlay', 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const userPlugin = join(testDir, 'user-plugin.js') + const overlayPlugin = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(userPlugin, 'user original source', 'utf8') + symlinkSync(userPlugin, overlayPlugin) + + writeOverlayOpenCodePluginAtomically(overlayPlugin, 'orca status source') + + expect(lstatSync(overlayPlugin).isSymbolicLink()).toBe(false) + expect(lstatSync(overlayPlugin).isFile()).toBe(true) + expect(readFileSync(overlayPlugin, 'utf8')).toBe('orca status source') + expect(readFileSync(userPlugin, 'utf8')).toBe('user original source') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('creates missing directories automatically when writing plugin', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-nested-dir-')) + const deeplyNestedPlugin = join(testDir, 'nested', 'path', 'plugins', 'status.js') + + writeOverlayOpenCodePluginAtomically(deeplyNestedPlugin, 'content') + expect(existsSync(deeplyNestedPlugin)).toBe(true) + expect(readFileSync(deeplyNestedPlugin, 'utf8')).toBe('content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('resolves long dangling symlink chains and creates target without replacing intermediate links', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-long-symlinks-')) + const missingTarget = join(testDir, 'final-target.js') + let current = missingTarget + const links: string[] = [] + for (let i = 0; i < 15; i++) { + const nextLink = join(testDir, `link-${i}.js`) + symlinkSync(current, nextLink) + current = nextLink + links.push(nextLink) + } + + writeCanonicalOpenCodePluginAtomically(current, 'long chain content') + + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('long chain content') + for (const link of links) { + expect(lstatSync(link).isSymbolicLink()).toBe(true) + } + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('throws on symlink loop without replacing intermediate symlinks', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-loop-symlinks-')) + const linkA = join(testDir, 'link-a.js') + const linkB = join(testDir, 'link-b.js') + symlinkSync(linkB, linkA) + symlinkSync(linkA, linkB) + + expect(() => writeCanonicalOpenCodePluginAtomically(linkA, 'loop content')).toThrow( + /ELOOP|symbolic link/i + ) + expect(lstatSync(linkA).isSymbolicLink()).toBe(true) + expect(lstatSync(linkB).isSymbolicLink()).toBe(true) + + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts new file mode 100644 index 000000000000..cd90bb2f3db3 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { renameFileWithWindowsRetry } from './windows-retry-file-operations' + +function isEnoentError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +// Why: atomic rename on a dotfiles symlink replaces the link itself; resolving canonical target updates the real repo file. +export function resolveCanonicalPluginWritePath(pluginPath: string): string { + try { + const stat = lstatSync(pluginPath) + if (!stat.isSymbolicLink()) { + return pluginPath + } + } catch (error) { + if (isEnoentError(error)) { + return pluginPath + } + throw error + } + + // Why: realpathSync.native resolves canonical target when it exists; + // if target is missing (dangling symlink), follow readlinkSync chain so the + // target file is created at the intended destination and the symlink stays intact. + try { + return realpathSync.native(pluginPath) + } catch (error) { + if (!isEnoentError(error)) { + throw error + } + } + + const visited = new Set([pluginPath]) + let current = pluginPath + for (let depth = 0; depth < 40; depth++) { + try { + const link = readlinkSync(current) + current = resolve(dirname(current), link) + if (visited.has(current)) { + throw new Error(`Symbolic link loop detected resolving "${pluginPath}"`) + } + visited.add(current) + const nextStat = lstatSync(current) + if (!nextStat.isSymbolicLink()) { + return current + } + } catch (error) { + if (isEnoentError(error)) { + return current + } + throw error + } + } + throw new Error(`Too many levels of symbolic links resolving "${pluginPath}"`) +} + +// Why: write to sibling temp file and rename so concurrent reloads never observe a truncated or missing file. +function writeAtomicFile(targetPath: string, content: string): void { + const dir = dirname(targetPath) + mkdirSync(dir, { recursive: true }) + let existingMode: number | undefined + try { + existingMode = statSync(targetPath).mode + } catch { + // Target does not exist yet. + } + const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + renameFileWithWindowsRetry(tmpPath, targetPath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // Best effort cleanup. + } + } + } +} + +// Why: preserve dotfile symlinks at canonical paths by atomically replacing the real target file. +export function writeCanonicalOpenCodePluginAtomically(pluginPath: string, source: string): void { + const targetPath = resolveCanonicalPluginWritePath(pluginPath) + writeAtomicFile(targetPath, source) +} + +// Why: replace any mirrored symlink directly in the overlay without following it to the user's config. +export function writeOverlayOpenCodePluginAtomically(pluginPath: string, source: string): void { + writeAtomicFile(pluginPath, source) +} diff --git a/src/shared/opencode-tui-plugin-install.test.ts b/src/shared/opencode-tui-plugin-install.test.ts new file mode 100644 index 000000000000..18e08a4b5f55 --- /dev/null +++ b/src/shared/opencode-tui-plugin-install.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, it } from 'vitest' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin } from './opencode-tui-plugin-install' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function linkedEntry(): { plugins: string; entry: string; target: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + const plugins = join(root, 'plugins') + const entry = join(plugins, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const target = join(root, 'user-plugin.js') + mkdirSync(join(plugins, openCodeTuiPluginDirName('orca-status.js')), { recursive: true }) + writeFileSync(target, 'old') + symlinkSync(target, entry) + return { plugins, entry, target } +} + +it.skipIf(process.platform === 'win32')( + 'updates a canonical TUI symlink target and preserves the link', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'new') + expect(lstatSync(entry).isSymbolicLink()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('new') + } +) + +it.skipIf(process.platform === 'win32')( + 'detaches an overlay TUI link even when its user bytes match', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'old', 'overlay') + expect(lstatSync(entry).isFile()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('old') + } +) + +it('does not reload a current TUI plugin by changing its timestamp', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + const entry = join(root, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const before = statSync(entry).mtimeMs + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + expect(statSync(entry).mtimeMs).toBe(before) +}) diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index db05aa8834d3..cad825212a8c 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -1,6 +1,13 @@ -import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' import { join } from 'node:path' -import { isInstalledOpenCodePluginCurrent } from './opencode-installed-plugin' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from './opencode-installed-plugin' /** * Directory holding the TUI copy of a status plugin file. OpenCode 2 loads a @@ -19,18 +26,20 @@ export function openCodeTuiPluginDirName(pluginFileName: string): string { export function writeOpenCodeTuiPlugin( pluginsDir: string, pluginFileName: string, - source: string + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' ): void { const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) const entry = join(dir, 'tui.js') - if (isInstalledOpenCodePluginCurrent(entry, source)) { + const isCurrent = + ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent + if (isCurrent(entry, source)) { return } mkdirSync(dir, { recursive: true }) - try { - unlinkSync(entry) - } catch { - // First install, or nothing to replace. - } - writeFileSync(entry, source) + const write = + ownership === 'canonical' + ? writeCanonicalOpenCodePluginAtomically + : writeOverlayOpenCodePluginAtomically + write(entry, source) } diff --git a/src/shared/windows-retry-file-operations.ts b/src/shared/windows-retry-file-operations.ts new file mode 100644 index 000000000000..759bada16e9d --- /dev/null +++ b/src/shared/windows-retry-file-operations.ts @@ -0,0 +1,69 @@ +import { copyFileSync, renameSync } from 'node:fs' +import { rename } from 'node:fs/promises' +import { setTimeout } from 'node:timers/promises' + +// Why: on Windows, file replacement and backup-copy operations can fail with +// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) +// holds the target file open. A short retry avoids transient failures without +// masking real permission errors. Total backoff (~750ms) covers typical AV +// scan windows seen in issue #1507. +export function renameFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => renameSync(source, target)) +} + +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + +export function copyFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) +} + +function runFileOperationWithWindowsRetry(operation: () => void): void { + for (let attempt = 1; ; attempt++) { + try { + operation() + return + } catch (error) { + if (shouldRetryFileOperation(error, attempt)) { + sleepSync(attempt * 50) + continue + } + throw error + } + } +} + +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + +// Why: writeFileAtomically is a sync API called from sync paths, so the retry +// backoff must park the thread instead of burning CPU in a Date.now() loop. +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) +function sleepSync(ms: number): void { + Atomics.wait(sleepBuffer, 0, 0, ms) +} From 10dda1245d74af915e9742047ee5dc5241813169 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:08:14 -0700 Subject: [PATCH 30/93] fix(opencode): retain plugin permissions across restrictive umasks --- src/shared/opencode-plugin-atomic-write.ts | 6 ++- .../opencode-plugin-permissions.test.ts | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/shared/opencode-plugin-permissions.test.ts diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts index cd90bb2f3db3..1ef27089bd00 100644 --- a/src/shared/opencode-plugin-atomic-write.ts +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -71,13 +72,16 @@ function writeAtomicFile(targetPath: string, content: string): void { mkdirSync(dir, { recursive: true }) let existingMode: number | undefined try { - existingMode = statSync(targetPath).mode + existingMode = statSync(targetPath).mode & 0o777 } catch { // Target does not exist yet. } const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) try { writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + if (existingMode !== undefined) { + chmodSync(tmpPath, existingMode) + } renameFileWithWindowsRetry(tmpPath, targetPath) } finally { if (existsSync(tmpPath)) { diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts new file mode 100644 index 000000000000..213f062e1454 --- /dev/null +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from './child-process/run-process' +import { fileURLToPath } from 'node:url' +it.skipIf(process.platform === 'win32')( + 'retains existing permissions when the process umask is stricter', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) + const pluginPath = join(root, 'plugin.js') + const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + try { + const result = await runProcess({ + program: process.execPath, + args: [ + '--import', + 'tsx', + '--input-type=module', + '-e', + ` + import { chmodSync, statSync, writeFileSync } from 'node:fs'; + import { createRequire } from 'node:module'; + const { writeCanonicalOpenCodePluginAtomically } = createRequire(import.meta.url)(${JSON.stringify(modulePath)}); + const target = ${JSON.stringify(pluginPath)}; + writeFileSync(target, 'old'); + chmodSync(target, 0o664); + process.umask(0o027); + writeCanonicalOpenCodePluginAtomically(target, 'new'); + console.log(statSync(target).mode & 0o777); + ` + ], + timeoutMs: 10_000, + maxOutputBytes: 4_096 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe(String(0o664)) + expect(readFileSync(pluginPath, 'utf8')).toBe('new') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) From 7cce878e7344ba0f05cdc1e99dadbfb83b12384e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:48:34 -0700 Subject: [PATCH 31/93] test(opencode): resolve permission fixture from primary cwd --- src/shared/opencode-plugin-permissions.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts index 213f062e1454..bdf6f5fa3b91 100644 --- a/src/shared/opencode-plugin-permissions.test.ts +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -1,15 +1,14 @@ import { expect, it } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { runProcess } from './child-process/run-process' -import { fileURLToPath } from 'node:url' it.skipIf(process.platform === 'win32')( 'retains existing permissions when the process umask is stricter', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) const pluginPath = join(root, 'plugin.js') - const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + const modulePath = resolve(process.cwd(), 'src/shared/opencode-plugin-atomic-write.ts') try { const result = await runProcess({ program: process.execPath, From d31fd32c822f33dc8ed635e3761a2e4da68d501b Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:00:27 -0700 Subject: [PATCH 32/93] feat(opencode): install startup prompt plugin independently of status hooks --- src/main/opencode/hook-service.ts | 18 +++- .../opencode-startup-prompt-installer.test.ts | 72 ++++++++++++++ .../opencode-startup-prompt-installer.ts | 12 +++ src/relay/opencode-overlay-mirror.ts | 40 ++++++++ .../opencode-startup-prompt-install.test.ts | 86 +++++++++++++++++ src/relay/plugin-overlay.ts | 96 +++++++++---------- src/relay/relay-agent-hook-runtime.ts | 7 ++ src/relay/wsl-install-plugins-handler.ts | 24 +++++ src/shared/opencode-startup-prompt-install.ts | 17 ++++ src/shared/opencode-tui-plugin-install.ts | 16 +++- 10 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index d7ce098bcec4..3d90f2f89d5b 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -28,7 +28,8 @@ import { } from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, - writeOpenCodeTuiPlugin + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory } from '../../shared/opencode-tui-plugin-install' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } @@ -54,6 +55,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -73,6 +75,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -92,6 +95,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -134,6 +138,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -260,6 +267,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -288,6 +296,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source, 'overlay') + return + } this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { writeOverlayOpenCodePluginAtomically(pluginPath, source) @@ -299,6 +311,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source) + return + } this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeCanonicalOpenCodePluginAtomically(pluginPath, source) diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 000000000000..628bc464335f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 000000000000..06c8d52e4c84 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,12 @@ +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 000000000000..0bd5e0b0f6c8 --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 000000000000..71cb373511e3 --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e0..08c9f772bed7 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 1dcda669f7d9..e7d37b6ed3e9 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -183,17 +183,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -210,8 +213,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea83..e66ff7d20a5e 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 000000000000..6427a9e98864 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index cad825212a8c..055aa3b7012e 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -29,7 +29,21 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent From b9e17f74bc4fde069f429f439d2a62ac359e64fa Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:07:13 -0700 Subject: [PATCH 33/93] Check relay environment values before merging --- src/relay/pty-handler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index eda7157d4749..0ffae2285904 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -852,7 +852,9 @@ export class PtyHandler { explicitEnv ) const baseEnv: Record = Object.fromEntries( - Object.entries(mergedEnv).filter(([, value]) => typeof value === 'string') + Object.entries(mergedEnv).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) ) const augmented: Record = {} for (const augmenter of this.envAugmenters) { @@ -866,7 +868,7 @@ export class PtyHandler { } const result: Record = Object.fromEntries( Object.entries(mergeGitConfigEnvProtocol(baseEnv, augmented)).filter( - ([, value]) => typeof value === 'string' + (entry): entry is [string, string] => typeof entry[1] === 'string' ) ) result[ORCA_IMAGE_PROTOCOL_ENV] = ORCA_IMAGE_PROTOCOL_VALUE From 24ef5e8330f0e2ad8d727f9e5297a737404e4ce6 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:35:33 -0700 Subject: [PATCH 34/93] fix(opencode): wait for admitted startup intent and preserve failed-launch briefs --- .../server-startup-prompt-control.test.ts | 54 ++++++++ .../agent-hooks/server/server-lifecycle.ts | 42 +++---- .../agent-hooks/server/server-listeners.ts | 5 +- src/main/agent-hooks/server/server-state.ts | 2 +- .../server/server-status-hook-lifecycle.ts | 52 ++++++++ src/main/ipc/pty/host-env/assembly.ts | 4 + .../opencode-hook-installation.test.ts | 22 ++++ src/main/opencode/hook-service.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 54 +++++++- src/main/opencode/opencode-pty-launch.ts | 18 ++- .../opencode-startup-prompt-claims.test.ts | 25 ++++ .../opencode-startup-prompt-claims.ts | 21 +++- .../opencode-startup-prompt-installer.test.ts | 27 +++- .../opencode-startup-prompt-installer.ts | 52 ++++++++ .../opencode-startup-prompt-owner.test.ts | 117 ++++++++++++++++++ .../opencode/opencode-startup-prompt-owner.ts | 39 ++++-- .../opencode-startup-prompt-source.test.ts | 49 ++++++++ .../opencode-startup-prompt-source.ts | 10 +- src/main/orcad/orcad-entry.ts | 8 ++ src/main/runtime/orca-runtime-runtime-id.ts | 9 +- .../startup/main-process-ready-foundation.ts | 2 + 21 files changed, 562 insertions(+), 56 deletions(-) create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts index ebc96e63dea3..1704b518c7ad 100644 --- a/src/main/agent-hooks/server-startup-prompt-control.test.ts +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -7,6 +7,60 @@ import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-fil import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' describe('startup prompt control with status hooks disabled', () => { + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 49fb90321139..858d6d0b860f 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,13 +11,12 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string @@ -26,6 +25,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } this.statusHooksEnabled = options?.statusHooksEnabled !== false @@ -40,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (this.statusHooksEnabled && !this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -89,7 +69,13 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const body = await readRequestBody(req) if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) return } if (!this.statusHooksEnabled) { diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index ec03eefd98bc..dc89f4f18008 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,7 +25,10 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { - setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { this.onStartupPromptClaim = listener this.clearStartupPromptClaims = clear } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 2889ba293d97..e9c4c9607e65 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,7 +92,7 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null - protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null protected clearStartupPromptClaims: (() => void) | null = null protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 000000000000..15a43182ab28 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7a..b22006f7b25b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { OPENCODE_CONFIG_DIR_ENV_KEYS, isOpenCodeLegacySharedConfigDir @@ -341,5 +342,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb9427..dc62b1fad91a 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3d90f2f89d5b..3515f53ba3f8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -121,13 +121,15 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 27f327a4beac..d265ecc8747a 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -34,24 +34,38 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } -vi.mock('../agent-hooks/server', () => ({ - agentHookServer: { endpointFilePath: '/private/endpoint.env' } -})) +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) async function prepare(options: Parameters[0]) { return (await prepareOpenCodePtyLaunch(options)).env } const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' @@ -74,6 +88,37 @@ describe('execution-host OpenCode launch preparation', () => { .command ).toBe("opencode --prompt 'task'") }) + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( 'gates native intent against the executing %s capability', async (version) => { @@ -83,6 +128,7 @@ describe('execution-host OpenCode launch preparation', () => { const env = await prepare({ command: 'opencode --prompt task', env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 314bec8770ed..3a547d35c0e8 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -22,6 +22,8 @@ const intentKeys = [ import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -70,11 +72,15 @@ export async function prepareOpenCodePtyLaunch(options: { if (!capabilities || capabilities.pluginApi === 'unknown') { return { env, command } } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi + } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if ( capabilities.promptMode === 'prefill' && !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && requestedPrompt && body && command && @@ -92,13 +98,21 @@ export async function prepareOpenCodePtyLaunch(options: { !parsed.spans[last].divergesFromShell && !parsed.spans[last - 1].divergesFromShell ) { - command = command.slice(0, parsed.spans[last - 1].start).trimEnd() const endpoint = agentHookServer.endpointFilePath if (endpoint) { launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + installOpenCodeStartupPromptForLaunch(launchEnv, false) + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } for (const key of intentKeys) { if (launchEnv[key]) { options.envToDelete.splice(options.envToDelete.indexOf(key), 1) diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts index 1dae2785a51b..9d469b804731 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.test.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -35,6 +35,31 @@ describe('execution-owned startup prompt claims', () => { } }) + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + it('bounds pending claims and reclaims expired capacity without timers', () => { let now = 0 const claims = new OpenCodeStartupPromptClaims(() => now) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts index 9dabf9ce426e..02fb1e078c01 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -3,7 +3,7 @@ import type { TerminalRunFacts } from '../runtime/terminal-run-facts' type PromptClaim = { digest: string expiresAt: number - readOwner: () => TerminalRunFacts | null + readOwner: () => TerminalRunFacts | 'pending' | null cleanup: () => void expiry: ReturnType } @@ -35,6 +35,17 @@ export class OpenCodeStartupPromptClaims { return true } + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + cancel(nonce: string): void { const pending = this.pending.get(nonce) this.pending.delete(nonce) @@ -50,21 +61,25 @@ export class OpenCodeStartupPromptClaims { } } - claim(body: unknown): boolean { + claim(body: unknown): boolean | 'pending' { if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { return false } const pending = this.pending.get(body.nonce) - this.cancel(body.nonce) if ( !pending || pending.expiresAt <= this.now() || !('digest' in body) || body.digest !== pending.digest ) { + this.cancel(body.nonce) return false } const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + this.cancel(body.nonce) return owner?.freshSpawn === true && owner.firstUserInputAt === null } } diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts index 628bc464335f..6cc8d54824aa 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.test.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -3,7 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' -import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' let root: string let originalXdg: string | undefined @@ -30,6 +33,28 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) describe('OpenCode startup prompt installer', () => { + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') expect(service.buildPtyEnv('pane')).toEqual({}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts index 06c8d52e4c84..509d330d0f88 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -1,5 +1,15 @@ import { OpenCodeHookService } from './hook-service' import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { return new OpenCodeHookService({ @@ -10,3 +20,45 @@ export function createOpenCodeStartupPromptInstaller(source: () => string): Open tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY }) } + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = env.OPENCODE_CONFIG_DIR || resolveOpenCodeConfigDirectory(env) + const overlay = installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + if (restoreAfterShellStartup) { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 000000000000..eda73f48b553 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: () => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts index 72068fc81d40..be1dacba25db 100644 --- a/src/main/opencode/opencode-startup-prompt-owner.ts +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -9,15 +9,30 @@ import { } from '../../shared/opencode-startup-prompt' import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> const claims = new OpenCodeStartupPromptClaims() +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + export async function commitPtyWithOpenCodePromptIntent( context: { env?: Record spawnEnv?: Record - deps: { runtime?: OrcaRuntimeService } + deps: { runtime?: OpenCodePromptRuntime } result: PtySpawnResult - provider: IPtyProvider + provider: Pick args: { connectionId?: string | null } }, commit: () => Promise @@ -35,6 +50,12 @@ export async function commitPtyWithOpenCodePromptIntent | undefined result: PtySpawnResult - runtime: OrcaRuntimeService | undefined - provider: IPtyProvider + runtime: OpenCodePromptRuntime | undefined + provider: Pick connectionId?: string | null }): void { const { env, result, runtime, provider } = options @@ -62,17 +83,15 @@ export function bindOpenCodeStartupPromptOwner(options: { !digest || !launchToken ) { + if (nonce) { + claims.cancel(nonce) + } return } - agentHookServer.setStartupPromptClaimListener( - (body) => claims.claim(body), - () => claims.clear() - ) let unsubscribe = () => {} if ( - claims.register( + claims.admit( nonce, - digest, () => { if ( ptyOwnership.get(result.id) !== null || diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 09ea92d80015..279164adc087 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -21,6 +21,11 @@ let setup: PluginModule['default']['setup'] let claim: ReturnType class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } plainText = '' focused = true insertText(text: string) { @@ -79,6 +84,50 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries only explicitly pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('preserves typing that predates plugin setup without requesting owner permission', async () => { const f = fixture() f.editor.replace('early typing') diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 0a140a9ad079..d6df936f52db 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -21,7 +21,8 @@ async function claimStartupPrompt(nonce, digest, endpoint) { body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) }); if (!response.ok) return false; - return (await response.json()).allowed === true; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; } async function submitStartupPrompt(ctx) { const noop = async () => {}; @@ -41,6 +42,9 @@ async function submitStartupPrompt(ctx) { }); if (memory.settled) return noop; let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; const matches = (candidate) => typeof candidate?.plainText === "string" && createHash("sha256").update(candidate.plainText).digest("hex") === digest; const cleanup = () => { @@ -68,6 +72,7 @@ async function submitStartupPrompt(ctx) { try { if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } if (editor !== current) { if (seen) return settle(); editor?.off("line-info-change", changed); @@ -85,12 +90,13 @@ async function submitStartupPrompt(ctx) { claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint); claiming = false; + if (allowed === "pending") return; if (!allowed) return settle(); claimed = true; } if (disposed || memory.settled || Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || - !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + !editor.focused || !isComposer(editor) || editor.plainText !== (populated ? prompt : "")) return settle(); if (!populated) { populated = true; editor.insertText(prompt); diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 9178dfbf7625..b4058baa12ab 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -206,6 +206,14 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) + registerCleanup( + profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + ) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before // registerPtyHandlers so the IPC layer routes through the daemon from the first call. diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index c856b91e3852..83ade41d7cf2 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -246,9 +246,14 @@ export class OrcaRuntimeWithRuntimeId { readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } if ( - !pty || - pty.incarnationId !== incarnationId || pty.launchIncarnationId !== incarnationId || pty.launchToken !== launchToken || (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49a..c9ec3b30368d 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. From fffdb219bdfc4f080183e14b3cbe25c146e84b9a Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:43:30 -0700 Subject: [PATCH 35/93] fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan --- src/main/opencode/hook-service.ts | 33 +++++------------ .../opencode-overlay-manifest.test.ts | 32 +++++++++++++++++ .../opencode/opencode-overlay-manifest.ts | 29 +++++++++++++++ src/main/pty/overlay-mirror.test.ts | 29 ++++++++++++++- src/main/pty/overlay-mirror.ts | 35 ++++++++++++++++++- 5 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 src/main/opencode/opencode-overlay-manifest.test.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3515f53ba3f8..599f2750d236 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -10,12 +10,17 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, @@ -41,12 +46,6 @@ import { const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} type OpenCodeHookVariant = { pluginFileName: string @@ -201,20 +200,6 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), @@ -224,7 +209,7 @@ export class OpenCodeHookService { private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) } const overlayPluginsDir = join(overlayDir, 'plugins') @@ -232,13 +217,13 @@ export class OpenCodeHookService { if (entryName === this.pluginFileName) { continue } - safeRemoveTree(join(overlayPluginsDir, entryName)) + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) } } // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 000000000000..7dc0404a53e6 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 000000000000..5abd0328f257 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d2264225..1a741552719c 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92c..6a35844f8717 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) From 7f2ba67db4ccc0b1fca80a9ff97a72d4f60b2fbf Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:46:56 -0700 Subject: [PATCH 36/93] fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../serialize-grid-transcript-replay.test.ts | 5 + ...e-1-18-32-timed-boot-hidden-pane.meta.json | 9 + ...1-18-32-timed-boot-hidden-pane.timing.json | 1 + ...pencode-1-18-32-timed-boot-hidden-pane.txt | 1 + ...opencode-1-18-32-timed-boot-slow.meta.json | 9 + ...encode-1-18-32-timed-boot-slow.timing.json | 1 + .../opencode-1-18-32-timed-boot-slow.txt | 1 + ...ncode-1-18-32-timed-first-launch.meta.json | 9 + ...ode-1-18-32-timed-first-launch.timing.json | 1 + .../opencode-1-18-32-timed-first-launch.txt | 1 + ...de-2-0-18-timed-boot-hidden-pane.meta.json | 9 + ...-2-0-18-timed-boot-hidden-pane.timing.json | 1 + ...opencode-2-0-18-timed-boot-hidden-pane.txt | 1 + .../agent-transcript-replay-test-harness.ts | 35 ++++ ...draft-paste-ready-scanner-chunking.test.ts | 191 ++++++++++++++++++ .../launched-agent-composer-readiness.test.ts | 170 ++++++++++++++++ .../launched-agent-composer-readiness.ts | 37 ++++ ...pencode-worker-composer-transcript.test.ts | 101 +++++++++ .../orca-runtime-activate-managed-worktree.ts | 4 +- ...ederation-launched-agent-readiness.test.ts | 129 ++++++++++++ .../orchestration/federation/federation.ts | 6 +- .../worker/local-worker-start.ts | 17 +- .../worker/zcode-worker-readiness.test.ts | 44 ++-- src/shared/draft-paste-ready-scanner.test.ts | 37 +++- src/shared/draft-paste-ready-scanner.ts | 35 +++- src/shared/tui-agent-config-types.ts | 2 + src/shared/tui-agent-config.ts | 13 +- 27 files changed, 818 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/draft-paste-ready-scanner-chunking.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 28f0eed4459e..32a32166417c 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // Captured boots expose the existing background/cursor restore loss; serializer code is unchanged. + 'opencode-1-18-32-timed-boot-hidden-pane': 16, + 'opencode-1-18-32-timed-boot-slow': 16, + 'opencode-1-18-32-timed-first-launch': 16, + 'opencode-2-0-18-timed-boot-hidden-pane': 14, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..c7e2132fa288 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:40:33.073309Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: answered like a hidden Orca pane (silent on OSC colour queries), as a background worker pane is. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 42.8 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..0701a967c642 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":42761.8,"chunks":[[785.0,205],[786.9,71],[791.9,8],[793.5,57],[793.9,32],[794.0,16],[794.0,16],[794.1,24],[794.2,24],[794.2,24],[794.3,36],[794.4,24],[794.5,24],[795.2,1024],[795.8,1024],[796.3,1024],[796.7,1024],[796.9,1024],[797.2,961],[811.9,1024],[812.5,1024],[813.1,1024],[813.6,1024],[814.1,1024],[814.7,937],[2523.9,1024],[2524.9,1024],[2525.2,1024],[2525.5,1024],[2525.8,1024],[2526.1,1024],[2526.3,353],[2541.6,13],[2609.5,934],[2610.0,938],[2610.6,871],[2611.3,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..903a8f0f6968 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json new file mode 100644 index 000000000000..b05bbf658fe7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:39:12.532885Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: the slowest recorded boot: 3.9 s of silence after bracketed paste is enabled, and the longest gap (161 ms) between the bare OpenCode title and the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 76.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json new file mode 100644 index 000000000000..25aa23bb5bd3 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":76886.4,"chunks":[[2454.9,276],[2478.3,8],[2513.8,1024],[2518.9,1024],[2531.6,1024],[2539.1,1024],[2581.2,1024],[2583.9,1024],[2587.4,411],[2593.0,1024],[2602.3,1024],[2607.8,1024],[2621.8,1024],[2622.5,1024],[2623.3,937],[6479.2,1024],[6485.0,1024],[6487.5,1024],[6490.0,1024],[6499.8,1024],[6502.7,1024],[6509.0,353],[6538.4,13],[6685.0,934],[6686.4,938],[6697.9,871],[6699.1,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt new file mode 100644 index 000000000000..748053e1c3ee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json new file mode 100644 index 000000000000..e0ad565f9350 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:33:44.169427Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: first launch on an empty home: no first-run dialog appears before the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 14.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json new file mode 100644 index 000000000000..9c1d94e0a7a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":14932.3,"chunks":[[894.6,205],[897.3,71],[906.1,8],[909.0,57],[909.3,32],[909.4,16],[909.5,16],[909.6,24],[909.6,24],[909.8,24],[909.9,36],[910.0,24],[910.1,24],[911.8,134],[915.0,1024],[916.4,1024],[917.0,1024],[917.5,1024],[918.0,1024],[918.6,1024],[933.4,1024],[934.1,1024],[934.7,1024],[935.3,1024],[935.8,1024],[936.4,937],[2731.2,1024],[2731.8,1024],[2732.2,1024],[2732.7,1024],[2733.1,1024],[2734.0,1024],[2734.4,353],[2766.9,13],[2847.0,934],[2847.6,938],[2848.1,871],[2848.6,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt new file mode 100644 index 000000000000..2baf054700fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..5399222bf7ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:50:47.405221Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.18 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: OpenCode 2 answered like a hidden Orca pane; show-cursor repeats on every frame after the box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 41.5 s; no bytes after the last chunk. Run as `opencode --standalone` from a 2.0.18 binary; Orca's opencode2 row launches `opencode2 --standalone`. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..2a95872c1809 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":41502.8,"chunks":[[307.5,280],[316.3,1024],[316.7,1024],[317.0,1024],[317.4,1024],[317.7,1024],[318.0,1022],[318.3,164],[318.3,24],[318.3,24],[326.8,1024],[327.5,1024],[328.0,1024],[328.6,1024],[329.1,1024],[329.7,937],[1339.7,1024],[1340.4,1024],[1341.1,1024],[1341.8,1024],[1342.5,1024],[1343.1,1024],[1343.8,353],[1348.0,13],[1387.9,934],[1388.4,938],[1388.7,832],[1418.2,483],[1436.2,483],[1453.8,488],[1471.8,497],[1489.7,498],[1508.0,504],[1525.8,504],[1543.2,504],[1560.9,504]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..b2b9cd01dfee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/USER1/orca-qa/b9b-capture/workshift+tab agentsctrl+p commands2.0.18]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35e7726bea9d..7a52bd875aa1 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -22,6 +22,41 @@ export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +export type TimedRuntimeFixture = { + /** The recorded PTY reads, in order; they concatenate to the whole `.txt`. */ + chunks: string[] + /** ms since spawn at which each read arrived. */ + times: number[] + promptSentAtMs?: number + /** When recording stopped; no bytes arrived between the last read and this. */ + recordedUntilMs?: number +} + +/** `.timing.json` holds each read as [ms since spawn, UTF-16 length]. */ +export function readTimedRuntimeFixture(name: string): TimedRuntimeFixture { + const data = readRuntimeFixture(name) + const timing: { + chunks: [number, number][] + promptSentAtMs?: number + recordedUntilMs?: number + } = JSON.parse(readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + if (offset !== data.length) { + throw new Error(`${name}.timing.json covers ${offset} of ${data.length} chars`) + } + return { + chunks, + times: timing.chunks.map(([at]) => at), + ...(timing.promptSentAtMs !== undefined ? { promptSentAtMs: timing.promptSentAtMs } : {}), + ...(timing.recordedUntilMs !== undefined ? { recordedUntilMs: timing.recordedUntilMs } : {}) + } +} + /** Resizes the grid before chunk `atChunk`, as a PTY resize landing mid-paint would. */ export type TranscriptReplayResize = { atChunk: number; cols: number; rows: number } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts new file mode 100644 index 000000000000..8fbbf19d552d --- /dev/null +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -0,0 +1,191 @@ +/** + * The draft-paste scanner must answer the same way however the PTY splits a stream into reads. + * Every committed capture whose signal revokes its anchor is replayed whole, one char at a time, + * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on + * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and + * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +type Walk = { anchor: string; end: string; marker: string } + +const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': { + anchor: '\x1b[?2004h', + end: '\x1b[?2004l', + marker: '\x1b[?25h' + }, + 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } +} + +// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +const ZSH_LAUNCH_PROLOGUE = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + +const OPENCODE_TIMED = [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch', + 'opencode-2-0-18-timed-boot-hidden-pane' +] + +type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } + +function readPtyTranscript(name: string): string { + return readFileSync( + join(__dirname, '..', 'daemon', '__fixtures__', 'pty-transcripts', name), + 'utf8' + ) +} + +function grokCase(name: string, trace: GrokStartupTraceChunk[]): Case { + const reads = trace.map((chunk) => chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + return { name, signal: 'grok-composer-prompt', data: reads.join(''), reads } +} + +const CASES: Case[] = [ + grokCase('grok 1.0.0 alt screen', GROK_STARTUP_PTY_TRACE), + grokCase('grok 1.0.0 inline', GROK_INLINE_STARTUP_PTY_TRACE), + { + name: 'dsh-tui-ready-no-key', + signal: 'dsh-composer-prompt', + data: readRuntimeFixture('dsh-tui-ready-no-key') + }, + { + name: 'zcode-composer-ready', + signal: 'zcode-composer-prompt', + data: readRuntimeFixture('zcode-composer-ready') + }, + { + name: 'opencode (untimed pty transcript)', + signal: 'render-cursor-after-bracketed-paste', + data: readPtyTranscript('opencode.txt') + }, + ...OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch`, + signal: 'render-cursor-after-bracketed-paste', + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) +] + +/** End offset of the first marker seen while the anchor is held, walking the whole string. */ +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { + let cursor = 0 + for (;;) { + const enter = data.indexOf(anchor, cursor) + if (enter === -1) { + return null + } + cursor = enter + anchor.length + const leave = data.indexOf(end, cursor) + const found = data.indexOf(marker, cursor) + if (found !== -1 && (leave === -1 || found + marker.length <= leave)) { + return found + marker.length + } + if (leave === -1) { + return null + } + cursor = leave + end.length + } +} + +/** [start, end) of the first read the scanner reports ready on, or null. */ +function scanReadyRead(signal: DraftPasteReadySignal, reads: string[]): [number, number] | null { + const scanner = createDraftPasteReadyScanner(signal) + let offset = 0 + for (const read of reads) { + if (scanner.observe(read).ready) { + return [offset, offset + read.length] + } + offset += read.length + } + return null +} + +function splitAt(data: string, sizes: () => number): string[] { + const reads: string[] = [] + for (let offset = 0; offset < data.length;) { + const size = sizes() + reads.push(data.slice(offset, offset + size)) + offset += size + } + return reads +} + +// mulberry32: a fixed seed keeps every chunking reproducible. +function seededSizes(seed: number, max: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return 1 + Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * max) + } +} + +function chunkings(testCase: Case): Map { + const { data } = testCase + const result = new Map([ + ['whole', [data]], + ['one char per read', splitAt(data, () => 1)] + ]) + if (testCase.reads) { + result.set('recorded reads', testCase.reads) + } + for (let seed = 0; seed < 12; seed += 1) { + const max = seed % 2 === 0 ? 16 : 2048 + result.set(`random chunking ${seed}`, splitAt(data, seededSizes(seed + 1, max))) + } + return result +} + +describe('draft-paste readiness does not depend on how the stream is chunked', () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { + const walk = WALKS[testCase.signal] + if (!walk) { + throw new Error(`no reference walk for ${testCase.signal}`) + } + const expected = walkReadyEnd(testCase.data, walk) + for (const [label, reads] of chunkings(testCase)) { + const readyRead = scanReadyRead(testCase.signal, reads) + if (expected === null) { + expect(readyRead, label).toBeNull() + continue + } + expect(readyRead, label).not.toBeNull() + const [start, end] = readyRead! + expect( + start < expected && expected <= end, + `${label}: [${start}, ${end}) vs ${expected}` + ).toBe(true) + } + }) + + it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { + const walk = WALKS['render-cursor-after-bracketed-paste']! + const data = readTimedRuntimeFixture(name).chunks.join('') + const alone = walkReadyEnd(data, walk) + expect(alone).not.toBeNull() + expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 000000000000..7f8e7a18fa15 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + readRuntimeFixture, + readTimedRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + getLaunchedAgentReadinessLane, + type LaunchedAgentReadinessLane +} from './launched-agent-composer-readiness' + +// Why a full Record: adding a TuiAgent fails to compile here until someone decides whether its +// worker start waits for a captured input-box marker, and a row that gains or loses evidence +// shows up in review. +const EXPECTED_LANES: Record = { + zcode: 'composer-marker', + opencode: 'composer-marker', + opencode2: 'composer-marker', + // mimo-code shares OpenCode's signal by parity only; no mimo boot has been recorded. + 'mimo-code': 'tui-idle', + // Grok's inline mode never fires its marker; its marker is not proven at the box for every mode. + grok: 'tui-idle', + dsh: 'tui-idle', + codex: 'tui-idle', + cursor: 'tui-idle', + pi: 'tui-idle', + omp: 'tui-idle', + droid: 'tui-idle', + hermes: 'tui-idle', + devin: 'tui-idle', + claude: 'tui-idle', + 'claude-agent-teams': 'tui-idle', + openclaude: 'tui-idle', + gemini: 'tui-idle', + antigravity: 'tui-idle', + aider: 'tui-idle', + openclaw: 'tui-idle', + copilot: 'tui-idle', + muse: 'tui-idle', + qoder: 'tui-idle', + codebuddy: 'tui-idle', + autohand: 'tui-idle', + ante: 'tui-idle', + trae: 'tui-idle', + 'prime-agent': 'tui-idle', + goose: 'tui-idle', + amp: 'tui-idle', + kilo: 'tui-idle', + kiro: 'tui-idle', + crush: 'tui-idle', + aug: 'tui-idle', + cline: 'tui-idle', + codebuff: 'tui-idle', + freebuff: 'tui-idle', + 'command-code': 'tui-idle', + continue: 'tui-idle', + kimi: 'tui-idle', + 'mistral-vibe': 'tui-idle', + 'qwen-code': 'tui-idle', + rovo: 'tui-idle' +} + +const FIXTURES = join(__dirname, '__fixtures__') +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => + (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) +) + +function isTuiAgent(agent: string): agent is TuiAgent { + return agent in TUI_AGENT_CONFIG +} + +function readSignal(agent: string) { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + if (!signal) { + throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) + } + return signal +} + +/** Index of the first read the scanner reports ready on, or -1. */ +function firstReadyRead(agent: string, reads: readonly string[]): number { + const scanner = createDraftPasteReadyScanner(readSignal(agent)) + return reads.findIndex((read) => scanner.observe(read).ready) +} + +function readReads(capture: string): string[] { + if (existsSync(join(FIXTURES, `${capture}.timing.json`))) { + return readTimedRuntimeFixture(capture).chunks + } + const data = readRuntimeFixture(capture) + const reads: string[] = [] + for (let offset = 0; offset < data.length; offset += 4096) { + reads.push(data.slice(offset, offset + 4096)) + } + return reads +} + +describe('which lane a freshly launched worker waits on', () => { + it.each(Object.entries(EXPECTED_LANES))('%s: %s', (agent, lane) => { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + expect(getLaunchedAgentReadinessLane(agent)).toBe(lane) + }) +}) + +describe('every capture a row cites proves its input-box marker', () => { + it('cites at least the rows worker start moved', () => { + expect(CITED_CAPTURES.map(([agent]) => agent)).toEqual( + expect.arrayContaining(['zcode', 'opencode', 'opencode2']) + ) + }) + + it.each(CITED_CAPTURES)('%s: %s is committed with its sidecar', (_agent, capture) => { + expect(existsSync(join(FIXTURES, `${capture}.txt`))).toBe(true) + const meta: { platform?: string; command?: string[]; note?: string } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + expect(meta.platform).toEqual(expect.any(String)) + expect(meta.command?.length).toBeGreaterThan(0) + expect(meta.note).toEqual(expect.any(String)) + }) + + it.each(CITED_CAPTURES)("%s: the row's current signal fires on %s", (agent, capture) => { + expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) + }) + + it.each( + CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') + )( + '%s: the first ready read in %s is the one that shows the input box', + async (agent, capture) => { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + const { chunks } = readTimedRuntimeFixture(capture) + const data = chunks.join('') + // OpenCode paints its box in one synchronized update, shown when that update ends. + const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + let boxRead = -1 + for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { + end += chunks[index].length + boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 + } + let placeholderRead = -1 + let read = 0 + for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { + if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { + placeholderRead = read + break + } + read += 1 + } + expect(placeholderRead).toBeGreaterThanOrEqual(0) + expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) + expect(firstReadyRead(agent, chunks)).toBe(boxRead) + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 000000000000..dedfeba8823e --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,37 @@ +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { OrcaRuntimeService } from './orca-runtime' + +export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' +> + +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' + ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) +} + +export function waitForWorkerAgentReady( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + args: { agent: TuiAgent | undefined; reusesTerminal: boolean; timeoutMs: number } +): Promise { + // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + return args.agent && !args.reusesTerminal + ? waitForLaunchedAgentComposer(runtime, handle, args.agent, args.timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: args.timeoutMs }) +} diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..52aa2f4da86e --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,101 @@ +/** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title +// and the launcher's cursor toggle are synthetic. +const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] +] + +/** The read that ends the synchronized update drawing OpenCode's input box. */ +function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) +} + +async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { composer: number | null; tuiIdle: number | null } = { + composer: null, + tuiIdle: null + } + let read = -1 + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => (settledAt.composer ??= read)) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { settledAt, composer, boxRead: boxRead(chunks) } +} + +describe('an OpenCode worker gets its task only once its input box exists', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles on the read that shows the box', + async (name, agent) => { + const { settledAt, composer, boxRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 2f7e59a6d1f8..0b50aa9a5d77 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, @@ -182,7 +183,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( @@ -198,6 +199,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts new file mode 100644 index 000000000000..4a6da545fbcd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +const HANDLE = 'term_windows_worker' +const WORKTREE = 'repo::windows-worktree' + +// Why: `worker-start --on ` launches the agent through this attach on the worker host, so +// it must wait for the same input box a local start waits for, or the task is typed too early. +describe('federated worker first dispatch readiness', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const db of databases.splice(0)) { + db.close() + } + vi.restoreAllMocks() + }) + + function createWorkerHost(): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + return runtime + } + + async function attach( + runtime: OrcaRuntimeService, + target: { agent: string } | { terminal: string } + ): Promise { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + const placement = + 'agent' in target + ? { worktree: 'new-top-level', repo: 'windows-repo', name: 'remote-worker', ...target } + : { worktree: WORKTREE, ...target } + return await method.handler( + method.params!.parse({ + runId: 'run-home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote worker', + protocolVersion: 3, + ...placement + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_remote', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_payload' + } + } + ) + } + + it.each(['opencode', 'opencode2', 'zcode'] as const)( + 'a %s terminal this attach created waits for its input box, not tui-idle', + async (agent) => { + const runtime = createWorkerHost() + + await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + HANDLE, + agent, + resolveWorkerStartReadinessTimeoutMs(undefined) + ) + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + const runtime = createWorkerHost() + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. + { id: WORKTREE } as never + ) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + + await expect(attach(runtime, { terminal: HANDLE })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + }) + + it('fails at agent_readiness without typing the task when the input box never appears', async () => { + const runtime = createWorkerHost() + vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: 'timeout' + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index ca63c1fb46d7..6bd678e2f6d9 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { @@ -217,8 +218,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } persistFederatedReadinessStage(setupStage) failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', + const wait = await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b5..049f6503ac15 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -188,17 +189,11 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 30d32c2ede83..9421596669d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -1,28 +1,38 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('ZCode first dispatch readiness', () => { const h = createOrchestrationWorkerReleaseHarness() afterEach(() => h.cleanup()) - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) it('keeps reused terminals on the normal idle wait', async () => { h.setup() diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index a37111a2e22c..feaa40fa260a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' @@ -87,6 +88,27 @@ describe('createDraftPasteReadyScanner', () => { }) }) + it('never joins a show-cursor across a chunk seam from bytes it already scanned', () => { + // Why: the stream holds only a hide-cursor; re-reading carried chars used to assemble a show. + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(DECSET_BRACKETED_PASTE).ready).toBe(false) + expect(scanner.observe('Search \x1b[?25').ready).toBe(false) + expect(scanner.observe('l more text').ready).toBe(false) + }) + + it('ignores a show-cursor after the shell turns bracketed paste back off to run a command', () => { + // zsh's prompt enables bracketed paste and disables it on accept-line, before the launcher + // runs; the launcher's cursor toggle stands in for any spinner (synthetic). + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}% opencode`).ready).toBe(false) + expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\n`).ready).toBe(false) + expect(scanner.observe(`${HIDE_CURSOR}resolving${SHOW_CURSOR}`).ready).toBe(false) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + it('ignores show-cursor that appears before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') // A pre-handshake cursor toggle must not trip readiness. @@ -272,17 +294,16 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('Windows ConPTY, which never forwards DECSET 2004', () => { - // Why: measured on a remote Windows host (terminal-agent-paste-bracketing.test.ts) — the - // agent's `\x1b[?2004h` is consumed by conhost and never reaches the client stream. Every - // signal below is anchored on it, so on Windows readiness cannot resolve and delivery always - // falls through to the caller's hard timeout and its blind process-ownership paste (#22479). - const WINDOWS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` + describe('a stream that never carries DECSET 2004', () => { + // Why: every signal below is anchored on `\x1b[?2004h`, so without it readiness cannot resolve + // and delivery falls through to the caller's hard timeout. A transport that loses the sequence + // lands here; terminal-agent-paste-bracketing.ts names remote replay and ConPTY as possible. + const ANCHORLESS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` it('never reports opencode ready from show-cursor frames alone', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') for (let frame = 0; frame < 5; frame += 1) { - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) @@ -291,7 +312,7 @@ describe('createDraftPasteReadyScanner', () => { it('never arms the default quiet window either', () => { const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste') - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f7a9cb7c0c52..c079c724c601 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,6 +4,7 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, @@ -48,7 +49,10 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const scanRevocableAnchorSegments = ( + window: string, + carriedLength: number, + anchor: string, + end: string + ): boolean => { let cursor = 0 while (cursor < window.length) { if (!sawMarkerAnchor) { @@ -191,7 +201,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) continue } const leaveIndex = window.indexOf(end, cursor) - const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex) + // Why: carried chars only rejoin a split anchor or leave; postAnchorRecent already holds + // them, so re-reading them could join a marker across the duplicated seam. + const segmentStart = Math.max(cursor, carriedLength) + const segment = + leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex) if ((postAnchorRecent + segment).includes(signalMarker ?? '')) { return true } @@ -251,9 +265,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned // output is never re-walked into a second enter/leave transition. + const carriedLength = anchorCarry.length const window = anchorCarry + data anchorCarry = window.slice(-ANCHOR_CARRY_CHARS) - if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) { + if (scanRevocableAnchorSegments(window, carriedLength, markerAnchor, markerAnchorEnd)) { return { ready: true, armQuietTimer: false } } } else if (!sawMarkerAnchor) { diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..6b3fcb464a48 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,6 +43,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Captured boots proving the composer marker used for fresh worker dispatch. */ + composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a7be259df2cd..e2a96d978cec 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch' + ] }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -108,7 +113,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] }, 'mimo-code': { detectCmd: 'mimo', @@ -311,7 +317,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', // Why: ZCode repaints an animated ASCII banner indefinitely, so the default quiet-render // window never settles; its composer box corner is the real "input is live" signal. - draftPasteReadySignal: 'zcode-composer-prompt' + draftPasteReadySignal: 'zcode-composer-prompt', + composerReadyCaptures: ['zcode-composer-ready'] }, devin: { detectCmd: 'devin', From 721806545bc91beafd0b5732ffa674f0eb4f74e1 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:19:38 -0700 Subject: [PATCH 37/93] fix(orchestration): validate configured aliases with target shell grammar --- ...a-runtime-get-terminal-interactive-wait.ts | 29 ++++++++++- ...hestration-configured-agent-target.test.ts | 49 +++++++++++++++++++ .../configured-worker-agent-selector.test.ts | 18 +++++++ .../configured-worker-agent-selector.ts | 12 +++-- .../orchestration/federation/federation.ts | 6 ++- .../worker/local-worker-start.ts | 22 +++++++-- .../worker-configured-agent-preflight.ts | 36 ++++++++++++++ 7 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 src/main/runtime/orchestration-configured-agent-target.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/worker-configured-agent-preflight.ts diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 2d3b4496d3f9..e1bd4ea94a94 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -14,6 +14,7 @@ import { selectExactWorkerProviderSession } from './orchestration/worker-provide import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { OrchestrationError } from './orchestration/orchestration-error' +import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' @@ -186,8 +187,32 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd }) } - resolveOrchestrationAgentLauncher(selector: string): TuiAgent | undefined { - return resolveConfiguredWorkerAgent(selector, this.store?.getSettings().agentCmdOverrides ?? {}) + resolveOrchestrationAgentLauncher( + selector: string, + platform: NodeJS.Platform = process.platform + ): TuiAgent | undefined { + return resolveConfiguredWorkerAgent( + selector, + this.store?.getSettings().agentCmdOverrides ?? {}, + platform + ) + } + + async resolveOrchestrationAgentLauncherForTarget( + selector: string, + target: { repo?: string; worktree?: string } + ): Promise { + if (isTuiAgent(selector)) { + return selector + } + const repo = target.repo ? await this.resolveRepoSelector(target.repo) : null + const workspace = repo + ? { repo, path: repo.path, connectionId: repo.connectionId } + : await this.resolveTerminalWorkspaceLaunchScope(target.worktree) + return this.resolveOrchestrationAgentLauncher( + selector, + this.getAgentLaunchPlatformForWorkspace(workspace) + ) } async probeOrchestrationOpenCodeModelLaunchSupport(target: { diff --git a/src/main/runtime/orchestration-configured-agent-target.test.ts b/src/main/runtime/orchestration-configured-agent-target.test.ts new file mode 100644 index 000000000000..00c9c9202b2b --- /dev/null +++ b/src/main/runtime/orchestration-configured-agent-target.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' + +it.each(['folder', 'ssh', 'wsl'] as const)( + 'resolves the %s target platform before interpreting an alias', + async (kind) => { + const scope = { + path: kind === 'wsl' ? '\\\\wsl.localhost\\Ubuntu\\repo' : '/opt/repo', + connectionId: kind === 'ssh' ? 'ssh-1' : null + } + const context = { + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => scope), + getAgentLaunchPlatformForWorkspace: vi.fn(() => 'linux' as const), + resolveOrchestrationAgentLauncher: vi.fn((selector: string, platform: NodeJS.Platform) => + resolveConfiguredWorkerAgent( + selector, + { opencode: '/opt/My\\ Agent/opencode-private' }, + platform + ) + ) + } + const result = await Reflect.apply( + OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, + context, + ['opencode-private', { worktree: 'id:workspace' }] + ) + expect(result).toBe('opencode') + expect(context.getAgentLaunchPlatformForWorkspace).toHaveBeenCalledWith(scope) + expect(context.resolveOrchestrationAgentLauncher).toHaveBeenCalledWith( + 'opencode-private', + 'linux' + ) + } +) + +describe('canonical worker agent target', () => { + it('keeps canonical selectors authoritative without probing another host', async () => { + const resolve = vi.fn() + expect( + await Reflect.apply( + OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, + { resolveTerminalWorkspaceLaunchScope: resolve }, + ['opencode', { worktree: 'id:remote' }] + ) + ).toBe('opencode') + expect(resolve).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts index d5cd8db7757b..f16393f7aeab 100644 --- a/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts @@ -40,3 +40,21 @@ it('refuses ambiguous aliases instead of selecting a different provider grammar' }) ).toThrow('multiple launchers') }) + +it.each([ + 'echo;/tmp/opencode-private', + 'echo&&/tmp/opencode-private', + '$(echo /tmp)/opencode-private', + '`echo /tmp`/opencode-private' +])('refuses shell-divergent override %s', (opencode) => { + expect(resolveConfiguredWorkerAgent('opencode-private', { opencode }, 'linux')).toBeUndefined() +}) +it('uses target POSIX grammar for an escaped-space guest executable', () => { + const command = '/opt/My\\ Agent/opencode-private' + expect(resolveConfiguredWorkerAgent('opencode-private', { opencode: command }, 'linux')).toBe( + 'opencode' + ) + expect( + resolveConfiguredWorkerAgent('opencode-private', { opencode: command }, 'win32') + ).toBeUndefined() +}) diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.ts index 9499cff3d427..f4777a72cb9f 100644 --- a/src/main/runtime/orchestration/configured-worker-agent-selector.ts +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.ts @@ -1,4 +1,4 @@ -import { basename, win32 } from 'node:path' +import { getCommandTokenPathBasename } from '../../../shared/command-token-scanner' import type { TuiAgent } from '../../../shared/tui-agent' import { isTuiAgent } from '../../../shared/tui-agent-config' import { @@ -22,13 +22,15 @@ export function resolveConfiguredWorkerAgent( } const parsed = tokenizeStartupCommand(command, resolveStartupShell(platform)) // A command wrapper cannot attest which CLI grammar its arguments implement. - if (!parsed.ok || parsed.tokens.length !== 1) { + if ( + !parsed.ok || + parsed.tokens.length !== 1 || + parsed.spans.some((span) => span.divergesFromShell) + ) { continue } const executable = parsed.tokens[0] - const name = ( - executable.includes('\\') ? win32.basename(executable) : basename(executable) - ).replace(/\.(?:exe|cmd|bat)$/i, '') + const name = getCommandTokenPathBasename(executable).replace(/\.(?:exe|cmd|bat)$/i, '') if (name === selector) { matches.push(agent) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 6a701e07ebf6..ea0d8fb7a72d 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -22,6 +22,7 @@ import { } from './federation-setup' import { FederationAttachStartParams } from './federation-start-schema' import { failFederatedAttachmentWithReceipt } from './federation-start-receipt' +import { resolveWorkerConfiguredAgentParams } from '../worker/worker-configured-agent-preflight' import { isWorkerStartTimeoutWithinTimerLimit, resolveWorkerStartReadinessTimeoutMs @@ -54,8 +55,11 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ ) } const createsWorktree = params.worktree === 'new-top-level' + const launchParams = await resolveWorkerConfiguredAgentParams(runtime, params, async () => + createsWorktree ? { repo: params.repo } : { worktree: params.worktree } + ) const { agent, launch } = await prepareFederationWorkerLaunchOnHost({ - params, + params: launchParams, createsWorktree, runtime }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 3764064b753a..974269e67077 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,4 +1,5 @@ import { probeWorkerOpenCodeModelLaunchSupport } from './worker-opencode-model-preflight' +import { resolveWorkerConfiguredAgentParams } from './worker-configured-agent-preflight' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -55,9 +56,22 @@ export async function startLocalWorker(args: { const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - const selectedAgent = params.agent - ? runtime.resolveOrchestrationAgentLauncher(params.agent) - : undefined + const launchParams = await resolveWorkerConfiguredAgentParams(runtime, params, async () => { + const callerWorkspaceId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) + const parent = createsWorktree + ? await runtime.showManagedWorktree(`id:${callerWorkspaceId}`) + : undefined + return createsWorktree + ? { repo: params.repo ?? parent?.repoId } + : { + worktree: requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree + } + }) + const selectedAgent = launchParams.agent let openCodeModelLaunchSupported = false if (selectedAgent === 'opencode' && params.model) { const callerWorkspaceId = await resolveDispatchCallerWorktreeId( @@ -80,7 +94,7 @@ export async function startLocalWorker(args: { ) } const { agent, launch } = prepareLocalWorkerStart({ - params, + params: launchParams, createsWorktree, runtime, openCodeModelLaunchSupported diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-configured-agent-preflight.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-configured-agent-preflight.ts new file mode 100644 index 000000000000..a61282c6d085 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-configured-agent-preflight.ts @@ -0,0 +1,36 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { isTuiAgent } from '../../../../../../shared/tui-agent-config' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { prepareFederationAttachmentWorkerStart } from './worker-start-validation' + +type WorkerAgentTarget = { repo?: string; worktree?: string } + +export async function resolveWorkerConfiguredAgentParams( + runtime: OrcaRuntimeService, + params: T, + resolveTarget: () => Promise +): Promise { + if (!params.agent || isTuiAgent(params.agent)) { + return params + } + const agent = await runtime.resolveOrchestrationAgentLauncherForTarget( + params.agent, + await resolveTarget() + ) + if (!agent) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured single-executable agent alias is required.' + ) + } + return { ...params, agent } +} + +export async function prepareFederationConfiguredWorkerStart( + args: Parameters[0] +) { + const params = await resolveWorkerConfiguredAgentParams(args.runtime, args.params, async () => + args.createsWorktree ? { repo: args.params.repo } : { worktree: args.params.worktree } + ) + return prepareFederationAttachmentWorkerStart({ ...args, params }) +} From 02a3334ee4e47e1bb5718c66ac842fd926798305 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:22:12 -0700 Subject: [PATCH 38/93] fix(orchestration): use actual shell and refuse assignment-only aliases --- ...a-runtime-get-terminal-interactive-wait.ts | 20 ++++++-- ...hestration-configured-agent-target.test.ts | 50 ++++++++++++++++--- .../configured-worker-agent-selector.test.ts | 20 ++++++++ .../configured-worker-agent-selector.ts | 10 +++- 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index e1bd4ea94a94..5a7a53267586 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -14,6 +14,8 @@ import { selectExactWorkerProviderSession } from './orchestration/worker-provide import type { TuiAgent } from '../../shared/tui-agent' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { OrchestrationError } from './orchestration/orchestration-error' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import { resolveStartupShell, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' @@ -189,12 +191,14 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd resolveOrchestrationAgentLauncher( selector: string, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + shell?: AgentStartupShell ): TuiAgent | undefined { return resolveConfiguredWorkerAgent( selector, this.store?.getSettings().agentCmdOverrides ?? {}, - platform + platform, + shell ) } @@ -209,10 +213,16 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd const workspace = repo ? { repo, path: repo.path, connectionId: repo.connectionId } : await this.resolveTerminalWorkspaceLaunchScope(target.worktree) - return this.resolveOrchestrationAgentLauncher( - selector, - this.getAgentLaunchPlatformForWorkspace(workspace) + const platform = this.getAgentLaunchPlatformForWorkspace(workspace) + const shell = resolveStartupShell( + platform, + resolveLocalWindowsAgentStartupShell({ + platform, + isRemote: Boolean(workspace.connectionId), + terminalWindowsShell: this.store?.getSettings().terminalWindowsShell + }) ) + return this.resolveOrchestrationAgentLauncher(selector, platform, shell) } async probeOrchestrationOpenCodeModelLaunchSupport(target: { diff --git a/src/main/runtime/orchestration-configured-agent-target.test.ts b/src/main/runtime/orchestration-configured-agent-target.test.ts index 00c9c9202b2b..4cdcc7dd7556 100644 --- a/src/main/runtime/orchestration-configured-agent-target.test.ts +++ b/src/main/runtime/orchestration-configured-agent-target.test.ts @@ -1,3 +1,4 @@ +import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' @@ -12,12 +13,14 @@ it.each(['folder', 'ssh', 'wsl'] as const)( const context = { resolveTerminalWorkspaceLaunchScope: vi.fn(async () => scope), getAgentLaunchPlatformForWorkspace: vi.fn(() => 'linux' as const), - resolveOrchestrationAgentLauncher: vi.fn((selector: string, platform: NodeJS.Platform) => - resolveConfiguredWorkerAgent( - selector, - { opencode: '/opt/My\\ Agent/opencode-private' }, - platform - ) + resolveOrchestrationAgentLauncher: vi.fn( + (selector: string, platform: NodeJS.Platform, shell?: AgentStartupShell) => + resolveConfiguredWorkerAgent( + selector, + { opencode: '/opt/My\\ Agent/opencode-private' }, + platform, + shell + ) ) } const result = await Reflect.apply( @@ -29,7 +32,8 @@ it.each(['folder', 'ssh', 'wsl'] as const)( expect(context.getAgentLaunchPlatformForWorkspace).toHaveBeenCalledWith(scope) expect(context.resolveOrchestrationAgentLauncher).toHaveBeenCalledWith( 'opencode-private', - 'linux' + 'linux', + 'posix' ) } ) @@ -47,3 +51,35 @@ describe('canonical worker agent target', () => { expect(resolve).not.toHaveBeenCalled() }) }) + +it('resolves local Windows shell settings rather than assuming PowerShell', async () => { + const context = { + store: { getSettings: () => ({ terminalWindowsShell: 'bash.exe' }) }, + resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({ + path: 'C:\\repo', + connectionId: null + })), + getAgentLaunchPlatformForWorkspace: vi.fn(() => 'win32' as const), + resolveOrchestrationAgentLauncher: vi.fn( + (selector: string, platform: NodeJS.Platform, shell?: AgentStartupShell) => + resolveConfiguredWorkerAgent( + selector, + { opencode: '/c/Agent\\ Directory/opencode-private.exe' }, + platform, + shell + ) + ) + } + expect( + await Reflect.apply( + OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, + context, + ['opencode-private', { worktree: 'id:workspace' }] + ) + ).toBe('opencode') + expect(context.resolveOrchestrationAgentLauncher).toHaveBeenCalledWith( + 'opencode-private', + 'win32', + 'posix' + ) +}) diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts index f16393f7aeab..16ae957da0a9 100644 --- a/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.test.ts @@ -58,3 +58,23 @@ it('uses target POSIX grammar for an escaped-space guest executable', () => { resolveConfiguredWorkerAgent('opencode-private', { opencode: command }, 'win32') ).toBeUndefined() }) + +it('refuses an assignment without an executable', () => { + expect( + resolveConfiguredWorkerAgent( + 'opencode-private', + { opencode: 'FOO=/tmp/opencode-private' }, + 'linux' + ) + ).toBeUndefined() +}) +it('uses the actual native Windows Git Bash grammar', () => { + expect( + resolveConfiguredWorkerAgent( + 'opencode-private', + { opencode: '/c/Agent\\ Directory/opencode-private.exe' }, + 'win32', + 'posix' + ) + ).toBe('opencode') +}) diff --git a/src/main/runtime/orchestration/configured-worker-agent-selector.ts b/src/main/runtime/orchestration/configured-worker-agent-selector.ts index f4777a72cb9f..959eb66c55cf 100644 --- a/src/main/runtime/orchestration/configured-worker-agent-selector.ts +++ b/src/main/runtime/orchestration/configured-worker-agent-selector.ts @@ -1,8 +1,10 @@ +import { extractLeadingEnvAssignments } from '../../../shared/command-environment' import { getCommandTokenPathBasename } from '../../../shared/command-token-scanner' import type { TuiAgent } from '../../../shared/tui-agent' import { isTuiAgent } from '../../../shared/tui-agent-config' import { resolveStartupShell, + type AgentStartupShell, tokenizeStartupCommand } from '../../../shared/tui-agent-startup-shell' import { OrchestrationError } from './orchestration-error' @@ -10,7 +12,8 @@ import { OrchestrationError } from './orchestration-error' export function resolveConfiguredWorkerAgent( selector: string, overrides: Partial>, - platform: NodeJS.Platform = process.platform + platform: NodeJS.Platform = process.platform, + shell?: AgentStartupShell ): TuiAgent | undefined { if (isTuiAgent(selector)) { return selector @@ -20,7 +23,7 @@ export function resolveConfiguredWorkerAgent( if (!isTuiAgent(agent) || !command) { continue } - const parsed = tokenizeStartupCommand(command, resolveStartupShell(platform)) + const parsed = tokenizeStartupCommand(command, resolveStartupShell(platform, shell)) // A command wrapper cannot attest which CLI grammar its arguments implement. if ( !parsed.ok || @@ -29,6 +32,9 @@ export function resolveConfiguredWorkerAgent( ) { continue } + if (extractLeadingEnvAssignments(parsed.tokens).env) { + continue + } const executable = parsed.tokens[0] const name = getCommandTokenPathBasename(executable).replace(/\.(?:exe|cmd|bat)$/i, '') if (name === selector) { From 3839f09319b994b557036c9ef815431711f0b327 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:35:33 -0700 Subject: [PATCH 39/93] fix(opencode): wait for admitted startup intent and preserve failed-launch briefs --- .../server-startup-prompt-control.test.ts | 54 ++++++++ .../agent-hooks/server/server-lifecycle.ts | 42 +++---- .../agent-hooks/server/server-listeners.ts | 5 +- src/main/agent-hooks/server/server-state.ts | 2 +- .../server/server-status-hook-lifecycle.ts | 52 ++++++++ src/main/ipc/pty/host-env/assembly.ts | 4 + .../opencode-hook-installation.test.ts | 22 ++++ src/main/opencode/hook-service.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 54 +++++++- src/main/opencode/opencode-pty-launch.ts | 18 ++- .../opencode-startup-prompt-claims.test.ts | 25 ++++ .../opencode-startup-prompt-claims.ts | 21 +++- .../opencode-startup-prompt-installer.test.ts | 27 +++- .../opencode-startup-prompt-installer.ts | 52 ++++++++ .../opencode-startup-prompt-owner.test.ts | 117 ++++++++++++++++++ .../opencode/opencode-startup-prompt-owner.ts | 39 ++++-- .../opencode-startup-prompt-source.test.ts | 49 ++++++++ .../opencode-startup-prompt-source.ts | 10 +- src/main/orcad/orcad-entry.ts | 8 ++ src/main/runtime/orca-runtime-runtime-id.ts | 9 +- .../startup/main-process-ready-foundation.ts | 2 + 21 files changed, 562 insertions(+), 56 deletions(-) create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts index ebc96e63dea3..1704b518c7ad 100644 --- a/src/main/agent-hooks/server-startup-prompt-control.test.ts +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -7,6 +7,60 @@ import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-fil import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' describe('startup prompt control with status hooks disabled', () => { + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 49fb90321139..858d6d0b860f 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,13 +11,12 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string @@ -26,6 +25,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } this.statusHooksEnabled = options?.statusHooksEnabled !== false @@ -40,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (this.statusHooksEnabled && !this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -89,7 +69,13 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const body = await readRequestBody(req) if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) return } if (!this.statusHooksEnabled) { diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index ec03eefd98bc..dc89f4f18008 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,7 +25,10 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { - setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { this.onStartupPromptClaim = listener this.clearStartupPromptClaims = clear } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 2889ba293d97..e9c4c9607e65 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,7 +92,7 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null - protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null protected clearStartupPromptClaims: (() => void) | null = null protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 000000000000..15a43182ab28 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 1cae20bfcbd2..43225ec45501 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { OPENCODE_CONFIG_DIR_ENV_KEYS, isOpenCodeLegacySharedConfigDir @@ -343,5 +344,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb9427..dc62b1fad91a 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3d90f2f89d5b..3515f53ba3f8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -121,13 +121,15 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 27f327a4beac..d265ecc8747a 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -34,24 +34,38 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } -vi.mock('../agent-hooks/server', () => ({ - agentHookServer: { endpointFilePath: '/private/endpoint.env' } -})) +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) async function prepare(options: Parameters[0]) { return (await prepareOpenCodePtyLaunch(options)).env } const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' @@ -74,6 +88,37 @@ describe('execution-host OpenCode launch preparation', () => { .command ).toBe("opencode --prompt 'task'") }) + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( 'gates native intent against the executing %s capability', async (version) => { @@ -83,6 +128,7 @@ describe('execution-host OpenCode launch preparation', () => { const env = await prepare({ command: 'opencode --prompt task', env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 314bec8770ed..3a547d35c0e8 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -22,6 +22,8 @@ const intentKeys = [ import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -70,11 +72,15 @@ export async function prepareOpenCodePtyLaunch(options: { if (!capabilities || capabilities.pluginApi === 'unknown') { return { env, command } } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi + } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if ( capabilities.promptMode === 'prefill' && !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && requestedPrompt && body && command && @@ -92,13 +98,21 @@ export async function prepareOpenCodePtyLaunch(options: { !parsed.spans[last].divergesFromShell && !parsed.spans[last - 1].divergesFromShell ) { - command = command.slice(0, parsed.spans[last - 1].start).trimEnd() const endpoint = agentHookServer.endpointFilePath if (endpoint) { launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + installOpenCodeStartupPromptForLaunch(launchEnv, false) + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } for (const key of intentKeys) { if (launchEnv[key]) { options.envToDelete.splice(options.envToDelete.indexOf(key), 1) diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts index 1dae2785a51b..9d469b804731 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.test.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -35,6 +35,31 @@ describe('execution-owned startup prompt claims', () => { } }) + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + it('bounds pending claims and reclaims expired capacity without timers', () => { let now = 0 const claims = new OpenCodeStartupPromptClaims(() => now) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts index 9dabf9ce426e..02fb1e078c01 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -3,7 +3,7 @@ import type { TerminalRunFacts } from '../runtime/terminal-run-facts' type PromptClaim = { digest: string expiresAt: number - readOwner: () => TerminalRunFacts | null + readOwner: () => TerminalRunFacts | 'pending' | null cleanup: () => void expiry: ReturnType } @@ -35,6 +35,17 @@ export class OpenCodeStartupPromptClaims { return true } + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + cancel(nonce: string): void { const pending = this.pending.get(nonce) this.pending.delete(nonce) @@ -50,21 +61,25 @@ export class OpenCodeStartupPromptClaims { } } - claim(body: unknown): boolean { + claim(body: unknown): boolean | 'pending' { if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { return false } const pending = this.pending.get(body.nonce) - this.cancel(body.nonce) if ( !pending || pending.expiresAt <= this.now() || !('digest' in body) || body.digest !== pending.digest ) { + this.cancel(body.nonce) return false } const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + this.cancel(body.nonce) return owner?.freshSpawn === true && owner.firstUserInputAt === null } } diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts index 628bc464335f..6cc8d54824aa 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.test.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -3,7 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' -import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' let root: string let originalXdg: string | undefined @@ -30,6 +33,28 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) describe('OpenCode startup prompt installer', () => { + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') expect(service.buildPtyEnv('pane')).toEqual({}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts index 06c8d52e4c84..509d330d0f88 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -1,5 +1,15 @@ import { OpenCodeHookService } from './hook-service' import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { return new OpenCodeHookService({ @@ -10,3 +20,45 @@ export function createOpenCodeStartupPromptInstaller(source: () => string): Open tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY }) } + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = env.OPENCODE_CONFIG_DIR || resolveOpenCodeConfigDirectory(env) + const overlay = installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + if (restoreAfterShellStartup) { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 000000000000..eda73f48b553 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: () => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts index 72068fc81d40..be1dacba25db 100644 --- a/src/main/opencode/opencode-startup-prompt-owner.ts +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -9,15 +9,30 @@ import { } from '../../shared/opencode-startup-prompt' import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> const claims = new OpenCodeStartupPromptClaims() +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + export async function commitPtyWithOpenCodePromptIntent( context: { env?: Record spawnEnv?: Record - deps: { runtime?: OrcaRuntimeService } + deps: { runtime?: OpenCodePromptRuntime } result: PtySpawnResult - provider: IPtyProvider + provider: Pick args: { connectionId?: string | null } }, commit: () => Promise @@ -35,6 +50,12 @@ export async function commitPtyWithOpenCodePromptIntent | undefined result: PtySpawnResult - runtime: OrcaRuntimeService | undefined - provider: IPtyProvider + runtime: OpenCodePromptRuntime | undefined + provider: Pick connectionId?: string | null }): void { const { env, result, runtime, provider } = options @@ -62,17 +83,15 @@ export function bindOpenCodeStartupPromptOwner(options: { !digest || !launchToken ) { + if (nonce) { + claims.cancel(nonce) + } return } - agentHookServer.setStartupPromptClaimListener( - (body) => claims.claim(body), - () => claims.clear() - ) let unsubscribe = () => {} if ( - claims.register( + claims.admit( nonce, - digest, () => { if ( ptyOwnership.get(result.id) !== null || diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 09ea92d80015..279164adc087 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -21,6 +21,11 @@ let setup: PluginModule['default']['setup'] let claim: ReturnType class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } plainText = '' focused = true insertText(text: string) { @@ -79,6 +84,50 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries only explicitly pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('preserves typing that predates plugin setup without requesting owner permission', async () => { const f = fixture() f.editor.replace('early typing') diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 0a140a9ad079..d6df936f52db 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -21,7 +21,8 @@ async function claimStartupPrompt(nonce, digest, endpoint) { body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) }); if (!response.ok) return false; - return (await response.json()).allowed === true; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; } async function submitStartupPrompt(ctx) { const noop = async () => {}; @@ -41,6 +42,9 @@ async function submitStartupPrompt(ctx) { }); if (memory.settled) return noop; let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; const matches = (candidate) => typeof candidate?.plainText === "string" && createHash("sha256").update(candidate.plainText).digest("hex") === digest; const cleanup = () => { @@ -68,6 +72,7 @@ async function submitStartupPrompt(ctx) { try { if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } if (editor !== current) { if (seen) return settle(); editor?.off("line-info-change", changed); @@ -85,12 +90,13 @@ async function submitStartupPrompt(ctx) { claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint); claiming = false; + if (allowed === "pending") return; if (!allowed) return settle(); claimed = true; } if (disposed || memory.settled || Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || - !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + !editor.focused || !isComposer(editor) || editor.plainText !== (populated ? prompt : "")) return settle(); if (!populated) { populated = true; editor.insertText(prompt); diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 9178dfbf7625..b4058baa12ab 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -206,6 +206,14 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) + registerCleanup( + profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + ) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before // registerPtyHandlers so the IPC layer routes through the daemon from the first call. diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index c856b91e3852..83ade41d7cf2 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -246,9 +246,14 @@ export class OrcaRuntimeWithRuntimeId { readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } if ( - !pty || - pty.incarnationId !== incarnationId || pty.launchIncarnationId !== incarnationId || pty.launchToken !== launchToken || (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49a..c9ec3b30368d 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. From 527f7f943810e56f0372d29cff714a945c9c9902 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:37:57 -0700 Subject: [PATCH 40/93] Support verified OpenCode v2 model selection in fresh native workers --- .../opencode-launch-model-config.test.ts | 76 +++++++ .../opencode/opencode-launch-model-config.ts | 33 +++ .../opencode-launch-model-context.test.ts | 142 +++++++++++++ .../opencode/opencode-launch-model-context.ts | 192 ++++++++++++++++++ .../opencode/opencode-model-availability.ts | 16 +- .../opencode-model-startup-plan.test.ts | 165 +++++++++++++++ .../opencode/opencode-model-startup-plan.ts | 149 ++++++++++++++ .../orca-runtime-create-agent-session.ts | 18 +- ...e-get-agent-session-execution-namespace.ts | 10 + ...a-runtime-get-terminal-interactive-wait.ts | 51 ++--- ...runtime-resolve-worktree-removal-target.ts | 9 +- .../orchestration-opencode-model-host.test.ts | 31 ++- 12 files changed, 835 insertions(+), 57 deletions(-) create mode 100644 src/main/opencode/opencode-launch-model-config.test.ts create mode 100644 src/main/opencode/opencode-launch-model-config.ts create mode 100644 src/main/opencode/opencode-launch-model-context.test.ts create mode 100644 src/main/opencode/opencode-launch-model-context.ts create mode 100644 src/main/opencode/opencode-model-startup-plan.test.ts create mode 100644 src/main/opencode/opencode-model-startup-plan.ts diff --git a/src/main/opencode/opencode-launch-model-config.test.ts b/src/main/opencode/opencode-launch-model-config.test.ts new file mode 100644 index 000000000000..65d183e156ff --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +describe('per-launch OpenCode model config', () => { + it('retains credentials, permissions and other agents while overriding the resolved primary model', () => { + const original = { + model: 'provider/old', + default_agent: 'reviewer', + providers: { + custom: { apiKey: 'placeholder-key', models: { preferred: { name: 'Preferred' } } } + }, + permissions: [{ tool: 'shell', action: 'ask' }], + agents: { + reviewer: { model: 'provider/agent-old', system: 'Review only', permissions: [] }, + build: { model: 'provider/build', steps: 5 }, + nested: { mode: 'subagent', model: 'provider/nested' } + } + } + const configContent = JSON.stringify(original) + const merged = resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'reviewer', + model: 'provider/preferred' + }) + expect(merged).not.toBeNull() + expect(JSON.parse(merged ?? 'null')).toEqual({ + ...original, + model: 'provider/preferred', + agents: { + ...original.agents, + reviewer: { ...original.agents.reviewer, model: 'provider/preferred' } + } + }) + expect(configContent).toBe(JSON.stringify(original)) + }) + + it('adds an override for the externally resolved built-in primary agent without guessing its name', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: undefined, + primaryAgent: 'plan', + model: 'provider/preferred' + }) + ).toBe('{"model":"provider/preferred","agents":{"plan":{"model":"provider/preferred"}}}') + }) + + it.each(['{', 'null', '[]', '{"agents":[]}', '{"agents":{"build":null}}'])( + 'refuses malformed inline config %s', + (configContent) => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent, + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + } + ) + + it('refuses oversized config and absent resolved preferences', () => { + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: ' '.repeat(1_048_577), + primaryAgent: 'build', + model: 'provider/a' + }) + ).toBeNull() + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: '{}', + primaryAgent: '', + model: 'provider/a' + }) + ).toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-config.ts b/src/main/opencode/opencode-launch-model-config.ts new file mode 100644 index 000000000000..c8a1d75f5ae6 --- /dev/null +++ b/src/main/opencode/opencode-launch-model-config.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' + +const launchConfig = z + .object({ agents: z.record(z.string(), z.record(z.string(), z.unknown())).optional() }) + .passthrough() + +export function resolveOpenCodeLaunchModelConfig(options: { + configContent: string | undefined + primaryAgent: string + model: string +}): string | null { + if (!options.primaryAgent || !options.model || (options.configContent?.length ?? 0) > 1_048_576) { + return null + } + try { + const raw: unknown = options.configContent?.trim() ? JSON.parse(options.configContent) : {} + const parsed = launchConfig.safeParse(raw) + if (!parsed.success) { + return null + } + const config = parsed.data + return JSON.stringify({ + ...config, + model: options.model, + agents: { + ...config.agents, + [options.primaryAgent]: { ...config.agents?.[options.primaryAgent], model: options.model } + } + }) + } catch { + return null + } +} diff --git a/src/main/opencode/opencode-launch-model-context.test.ts b/src/main/opencode/opencode-launch-model-context.test.ts new file mode 100644 index 000000000000..faf19bfdedf7 --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + parseOpenCodeLaunchModelContext, + probeOpenCodeLaunchModelContext +} from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +const directory = '/private/project' +const snapshot = { + directory, + models: { + location: { directory }, + data: [ + { id: 'one', providerID: 'provider', enabled: true }, + { id: 'disabled', providerID: 'provider', enabled: false } + ] + }, + agents: { + location: { directory }, + data: [ + { + id: 'reviewer', + mode: 'primary', + hidden: false, + model: { id: 'one', providerID: 'provider' } + }, + { id: 'build', mode: 'primary', hidden: false }, + { id: 'hidden', mode: 'primary', hidden: true }, + { id: 'child', mode: 'subagent', hidden: false } + ] + }, + config: [{ type: 'document', info: { default_agent: 'reviewer' } }], + defaultModel: { + location: { directory }, + data: { id: 'one', providerID: 'provider', enabled: true } + } +} + +describe('OpenCode execution-host model context', () => { + it('uses the configured visible primary and exact enabled provider/model pairs', () => { + expect(parseOpenCodeLaunchModelContext(snapshot)).toEqual({ + primaryAgent: 'reviewer', + availableModels: ['provider/one'], + primaryModel: 'provider/one' + }) + }) + + it('uses actual returned primary ordering and skips hidden agents and children', () => { + const agents = { + location: { directory }, + data: [...snapshot.agents.data.slice(2), ...snapshot.agents.data.slice(0, 2)] + } + expect(parseOpenCodeLaunchModelContext({ ...snapshot, agents })?.primaryAgent).toBe('reviewer') + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + defaultModel: { location: { directory }, data: null } + }) + ).toBeNull() + }) + + it('refuses an uninitialized, malformed or foreign-location response', () => { + for (const models of [ + null, + { location: { directory }, data: [] }, + { location: { directory: '/other' }, data: snapshot.models.data } + ]) { + expect(parseOpenCodeLaunchModelContext({ ...snapshot, models })).toBeNull() + } + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + agents: { location: { directory }, data: [] } + }) + ).toBeNull() + }) +}) + +describe.skipIf(!process.env.ORCA_REAL_OPENCODE_CLI_TEST)( + 'real current OpenCode model preflight', + () => { + it('waits for initialization and verifies a custom primary before and after a private overlay', async () => { + const executable = process.env.ORCA_REAL_OPENCODE_CLI_TEST + if (!executable) { + throw new Error('Real OpenCode executable required') + } + const home = await mkdtemp(join(tmpdir(), 'orca-opencode-model-context-')) + try { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!/API_KEY|TOKEN|SECRET|AUTH|CREDENTIAL|OPENCODE_|ORCA_/.test(key)) { + env[key] = value + } + } + for (const [key, child] of [ + ['HOME', 'home'], + ['XDG_CONFIG_HOME', 'config'], + ['XDG_DATA_HOME', 'data'], + ['XDG_STATE_HOME', 'state'], + ['XDG_CACHE_HOME', 'cache'] + ]) { + const path = join(home, child) + await mkdir(path, { mode: 0o700 }) + env[key] = path + } + env.ORCA_BACKGROUND_LAUNCH = '1' + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + default_agent: 'reviewer', + model: 'opencode/big-pickle', + agents: { + reviewer: { mode: 'primary', system: 'Review only', model: 'opencode/big-pickle' } + } + }) + const options = { executable, cwd: process.cwd(), env } + const before = await probeOpenCodeLaunchModelContext(options) + expect(before?.primaryAgent).toBe('reviewer') + expect(before?.primaryModel).toBe('opencode/big-pickle') + expect(before?.availableModels).toContain('opencode/fledge-alpha-free') + const config = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before?.primaryAgent ?? '', + model: 'opencode/fledge-alpha-free' + }) + expect(config).not.toBeNull() + const after = await probeOpenCodeLaunchModelContext({ + ...options, + env: { ...env, OPENCODE_CONFIG_CONTENT: config ?? '' }, + expectedPrimaryAgent: 'reviewer', + expectedPrimaryModel: 'opencode/fledge-alpha-free' + }) + expect(after?.primaryAgent).toBe('reviewer') + expect(after?.primaryModel).toBe('opencode/fledge-alpha-free') + expect(env.OPENCODE_CONFIG_CONTENT).toContain('opencode/big-pickle') + } finally { + await rm(home, { recursive: true, force: true }) + } + }, 30_000) + } +) diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts new file mode 100644 index 000000000000..ebb92eb50aec --- /dev/null +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -0,0 +1,192 @@ +import { randomBytes } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { spawnProcess } from '../../shared/child-process/run-process' +import { + forceTerminateProcessTree, + signalProcessTree +} from '../../shared/child-process/process-tree-termination' +import { withTimeout } from '../../shared/promise-timeout-fallback' +import { createOutputSink } from '../../shared/child-process/bounded-output-sink' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' + +const model = z.object({ id: z.string(), providerID: z.string() }) +const availableModel = model.extend({ enabled: z.boolean() }) +const agent = z.object({ + id: z.string(), + mode: z.enum(['primary', 'subagent', 'all']), + hidden: z.boolean(), + model: model.optional() +}) +const location = z.object({ directory: z.string() }) +const modelResponse = z.object({ location, data: availableModel.array() }) +const agentResponse = z.object({ location, data: agent.array() }) +const defaultModelResponse = z.object({ location, data: availableModel.nullable() }) +const configResponse = z.array( + z.object({ + type: z.enum(['directory', 'document']), + info: z.object({ default_agent: z.string().optional() }).optional() + }) +) + +export type OpenCodeLaunchModelContext = { + primaryAgent: string + availableModels: string[] + primaryModel: string | null +} + +export function parseOpenCodeLaunchModelContext(options: { + directory: string + models: unknown + agents: unknown + defaultModel: unknown + config: unknown +}): OpenCodeLaunchModelContext | null { + const models = modelResponse.safeParse(options.models) + const agents = agentResponse.safeParse(options.agents) + const defaultModel = defaultModelResponse.safeParse(options.defaultModel) + const config = configResponse.safeParse(options.config) + if ( + !models.success || + !agents.success || + !defaultModel.success || + !config.success || + models.data.location.directory !== options.directory || + agents.data.location.directory !== options.directory || + defaultModel.data.location.directory !== options.directory || + !defaultModel.data.data?.enabled || + agents.data.data.length === 0 + ) { + return null + } + // Installed 2.0.16 returns the selected default first; newer source has a different order. + const primary = agents.data.data.find((item) => item.mode !== 'subagent' && !item.hidden) + const configured = config.data.reduce( + (value, entry) => entry.info?.default_agent ?? value, + undefined + ) + const available = models.data.data.filter((item) => item.enabled) + if ( + !primary || + (configured !== undefined && primary.id !== configured) || + !available.some( + (item) => + item.id === defaultModel.data.data?.id && + item.providerID === defaultModel.data.data?.providerID + ) + ) { + return null + } + return { + primaryAgent: primary.id, + availableModels: available.map((item) => `${item.providerID}/${item.id}`), + primaryModel: primary.model ? `${primary.model.providerID}/${primary.model.id}` : null + } +} + +export async function probeOpenCodeLaunchModelContext(options: { + executable: string + cwd: string + env: NodeJS.ProcessEnv + signal?: AbortSignal + expectedPrimaryAgent?: string + expectedPrimaryModel?: string +}): Promise { + const password = randomBytes(32).toString('base64url') + const child = spawnProcess({ + program: options.executable, + args: ['serve', '--hostname', '127.0.0.1', '--port', '0'], + cwd: options.cwd, + env: { + ...options.env, + OPENCODE_SERVER_USERNAME: 'opencode', + OPENCODE_SERVER_PASSWORD: password + }, + detached: true + }) + const output = createOutputSink(4096) + const exited = new Promise((resolve) => child.once('close', () => resolve(true))) + let failed = false + child.on('error', () => { + failed = true + }) + child.once('exit', () => { + failed = true + }) + child.stdout.on('data', output.write) + child.stdout.on('error', () => { + failed = true + }) + child.stderr.on('error', () => { + failed = true + }) + child.stderr.resume() + child.stdin.on('error', () => { + failed = true + }) + child.stdin.end() + const signal = AbortSignal.any([ + AbortSignal.timeout(10_000), + ...(options.signal ? [options.signal] : []) + ]) + let context: OpenCodeLaunchModelContext | null = null + let stopped = false + try { + while (!failed && !signal.aborted && !output.truncated()) { + const address = output + .text() + .match(/^server listening on (http:\/\/127\.0\.0\.1:\d+)\s*$/m)?.[1] + if (address) { + const read = async (endpoint: string): Promise => { + const url = new URL(`/api/${endpoint}`, address) + url.searchParams.set('location[directory]', options.cwd) + const response = await fetch(url, { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` + }, + redirect: 'error', + signal + }) + if (!response.ok) { + await response.body?.cancel() + throw new Error('OpenCode model preflight refused') + } + return readFetchResponseJsonWithinLimit(response, 1_048_576) + } + const [models, agents, defaultModel, config] = await Promise.all([ + read('model'), + read('agent'), + read('model/default'), + read('config') + ]) + context = parseOpenCodeLaunchModelContext({ + directory: options.cwd, + models, + agents, + defaultModel, + config + }) + if ( + context && + (!options.expectedPrimaryAgent || + context.primaryAgent === options.expectedPrimaryAgent) && + (!options.expectedPrimaryModel || context.primaryModel === options.expectedPrimaryModel) + ) { + break + } + context = null + } + await delay(100, undefined, { signal }) + } + } catch { + context = null + } finally { + const signaled = await signalProcessTree(child, 'SIGTERM') + const closed = await withTimeout(exited, 1_500, false) + stopped = + process.platform === 'win32' && signaled && closed + ? true + : await forceTerminateProcessTree(child) + } + return stopped ? context : null +} diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts index b37f92dd7209..bb1e8d6422f6 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-availability.ts @@ -3,16 +3,16 @@ import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-ag import { runProcess } from '../../shared/child-process/run-process' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' -export async function probeOpenCodeModelAvailability(options: { +export async function resolveOpenCodeDirectModelExecutable(options: { command: string | undefined model: string env: NodeJS.ProcessEnv cwd?: string wsl?: { distro?: string } -}): Promise { +}): Promise { // WSL needs the same guest account/profile environment as its actual launch. if (options.wsl || !options.cwd) { - return false + return null } const parsed = tokenizeStartupCommand( options.command ?? '', @@ -24,12 +24,18 @@ export async function probeOpenCodeModelAvailability(options: { parsed.spans.some((span) => span.divergesFromShell) || !isAbsolute(parsed.tokens[0]) ) { - return false + return null } - const executable = await resolveCommandOnLocalPath(parsed.tokens[0], { + return resolveCommandOnLocalPath(parsed.tokens[0], { env: options.env, cwd: options.cwd }) +} + +export async function probeOpenCodeModelAvailability( + options: Parameters[0] +): Promise { + const executable = await resolveOpenCodeDirectModelExecutable(options) if (!executable) { return false } diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts new file mode 100644 index 000000000000..df3d805a0636 --- /dev/null +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from './opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { buildExecutionHostAgentStartupPlan } from './opencode-model-startup-plan' + +vi.mock('../managed-data-accounts/launch-environment', () => ({ + applyManagedDataAccountEnvironment: vi.fn() +})) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) +vi.mock('./opencode-model-availability', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn(), + probeOpenCodeModelAvailability: vi.fn() +})) +vi.mock('./opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() })) + +const preferred = 'opencode/fledge-alpha-free' +const original = JSON.stringify({ + default_agent: 'reviewer', + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: 'opencode/big-pickle' } } +}) +function scope() { + return { + inputs: resolveAgentStartupPlanInputs({ + agent: 'opencode', + settings: { + agentCmdOverrides: { opencode: '/private/opencode' }, + agentDefaultEnv: { opencode: { OPENCODE_CONFIG_CONTENT: original } } + }, + platform: process.platform, + isRemote: false, + sessionOptions: { model: preferred } + }), + cwd: '/private/project', + prompt: 'Read only', + hostIdentity: 'host' + } +} + +describe('execution-host OpenCode model startup', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/private/opencode') + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) + vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockResolvedValueOnce({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: preferred + }) + }) + + it('launches current v2 in isolation and keeps one-time model preferences out of resume state', async () => { + const options = scope() + const plan = await buildExecutionHostAgentStartupPlan(options) + expect(plan?.launchCommand).toContain('--standalone') + expect(plan?.launchCommand).not.toContain('--model') + expect(plan?.sessionOptions).toEqual({ model: preferred }) + expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ + default_agent: 'reviewer', + model: preferred, + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: preferred } } + }) + expect(plan?.launchConfig).toEqual({ + agentCommand: '/private/opencode', + agentArgs: '', + agentEnv: { OPENCODE_CONFIG_CONTENT: original } + }) + expect(options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT).toBe(original) + }) + + it('uses the selected account environment in both authoritative probes', async () => { + vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { + env.XDG_DATA_HOME = '/selected/data' + env.OPENCODE_DB = 'opencode.db' + }) + await buildExecutionHostAgentStartupPlan(scope()) + for (const call of vi.mocked(probeOpenCodeLaunchModelContext).mock.calls) { + expect(call[0].env).toMatchObject({ + XDG_DATA_HOME: '/selected/data', + OPENCODE_DB: 'opencode.db' + }) + } + }) + + it('preserves the verified legacy launch route', async () => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version: '1.18.30', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect((await buildExecutionHostAgentStartupPlan(scope()))?.launchCommand).toContain('--model') + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + }) + + it('preserves ordinary launches without any private server probe', async () => { + const options = scope() + options.inputs.sessionOptions = undefined + expect((await buildExecutionHostAgentStartupPlan(options))?.launchCommand).not.toContain( + '--standalone' + ) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + + it.each(['--agent build', '--continue', '--server http://localhost'])( + 'refuses current-v2 manual launch arguments %s', + async (agentArgs) => { + const options = scope() + options.inputs.agentArgs = agentArgs + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + } + ) + + it('refuses unavailable models and mismatched observed model preferences', async () => { + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ primaryAgent: 'reviewer', availableModels: [], primaryModel: null }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + vi.mocked(probeOpenCodeLaunchModelContext) + .mockReset() + .mockResolvedValue({ + primaryAgent: 'reviewer', + availableModels: [preferred], + primaryModel: 'opencode/big-pickle' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + }) + + it('refuses remote and WSL execution before touching the local binary', async () => { + const options = scope() + options.inputs.isRemote = true + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + options.inputs.isRemote = false + await expect( + buildExecutionHostAgentStartupPlan({ ...options, isWsl: true }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts new file mode 100644 index 000000000000..ee50b15a05c2 --- /dev/null +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -0,0 +1,149 @@ +import type { AgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' +import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildSleepingAgentLaunchConfig } from '../../shared/sleeping-agent-launch-config' +import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' +import { OrchestrationError } from '../runtime/orchestration/orchestration-error' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from './opencode-model-availability' +import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' + +type StartupScope = { + inputs: AgentStartupPlanInputs + cwd: string + isWsl?: boolean + hostIdentity?: string + signal?: AbortSignal +} + +function refuseModel(): never { + throw new OrchestrationError( + 'capability_unsupported', + 'The execution host cannot verify this OpenCode model launch.' + ) +} + +export async function prepareOpenCodeModelStartupInputs(options: StartupScope) { + const { inputs } = options + const model = inputs.sessionOptions?.model + if (inputs.agent !== 'opencode' || typeof model !== 'string') { + return { inputs } + } + if ( + inputs.isRemote || + options.isWsl || + inputs.platform !== process.platform || + options.signal?.aborted + ) { + refuseModel() + } + const command = + inputs.cmdOverrides.opencode || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, inputs.platform) + const env: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...inputs.agentEnv })) { + if (value !== undefined) { + env[key] = value + } + } + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const executable = await resolveOpenCodeDirectModelExecutable({ + command, + model, + cwd: options.cwd, + env + }) + if (!executable) { + refuseModel() + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: 'opencode', + cwd: options.cwd, + env, + hostIdentity: options.hostIdentity + }) + if (capabilities?.version === '1.18.30') { + if (!(await probeOpenCodeModelAvailability({ command, model, cwd: options.cwd, env }))) { + refuseModel() + } + return { inputs } + } + if (capabilities?.version !== '2.0.16' || inputs.agentArgs?.trim()) { + refuseModel() + } + const before = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env, + signal: options.signal + }).catch(() => null) + if (!before?.availableModels.includes(model)) { + refuseModel() + } + const configContent = resolveOpenCodeLaunchModelConfig({ + configContent: env.OPENCODE_CONFIG_CONTENT, + primaryAgent: before.primaryAgent, + model + }) + if (configContent === null) { + refuseModel() + } + const after = await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env: { ...env, OPENCODE_CONFIG_CONTENT: configContent }, + expectedPrimaryAgent: before.primaryAgent, + expectedPrimaryModel: model, + signal: options.signal + }).catch(() => null) + if ( + after?.primaryAgent !== before.primaryAgent || + after.primaryModel !== model || + !after.availableModels.includes(model) + ) { + refuseModel() + } + return { + inputs: { + ...inputs, + agentArgs: '--standalone', + agentEnv: { ...inputs.agentEnv, OPENCODE_CONFIG_CONTENT: configContent }, + sessionOptions: undefined + }, + launchConfig: buildSleepingAgentLaunchConfig({ + agentCommand: command, + agentArgs: inputs.agentArgs, + agentEnv: inputs.agentEnv + }) + } +} + +export async function buildExecutionHostAgentStartupPlan( + options: StartupScope & { + prompt: string + promptDelivery?: 'auto-submit' | 'draft' + } +) { + const prepared = await prepareOpenCodeModelStartupInputs(options) + if (prepared.launchConfig && options.promptDelivery === 'draft') { + refuseModel() + } + const plan = + options.promptDelivery === 'draft' + ? buildAgentDraftLaunchPlan({ ...prepared.inputs, draft: options.prompt }) + : buildAgentStartupPlan({ + ...prepared.inputs, + prompt: options.prompt, + allowEmptyPromptLaunch: true + }) + if (plan && prepared.launchConfig) { + plan.launchConfig = prepared.launchConfig + plan.sessionOptions = { ...options.inputs.sessionOptions } + } + return plan +} diff --git a/src/main/runtime/orca-runtime-create-agent-session.ts b/src/main/runtime/orca-runtime-create-agent-session.ts index 36e4d3f41a1b..b2ba25b7d739 100644 --- a/src/main/runtime/orca-runtime-create-agent-session.ts +++ b/src/main/runtime/orca-runtime-create-agent-session.ts @@ -17,7 +17,7 @@ import { } from './orca-runtime-core' import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' import type { RuntimeTerminalCreate } from '../../shared/runtime-types' import type { AgentSessionCreateOperation, @@ -159,14 +159,14 @@ export class OrcaRuntimeWithCreateAgentSession extends OrcaRuntimeWithGetAgentSe ...(request.agentArgs !== undefined ? { agentArgs: request.agentArgs } : {}), sessionOptions: this.toAgentSessionOptions(request.launchPreferences) }) - const startup = - request.promptDelivery === 'draft' - ? buildAgentDraftLaunchPlan({ ...startupArgs, draft: request.prompt ?? '' }) - : buildAgentStartupPlan({ - ...startupArgs, - prompt: request.prompt ?? '', - allowEmptyPromptLaunch: true - }) + const startup = await buildExecutionHostAgentStartupPlan({ + inputs: startupArgs, + cwd: startupCwd ?? workspace.path, + prompt: request.prompt ?? '', + promptDelivery: request.promptDelivery, + hostIdentity: this.runtimeId, + signal: caller.signal + }) if (!startup) { throw new Error('agent_session_identity_required') } diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index 7f968a6ba7d8..8b03eebd650e 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -13,6 +13,7 @@ import { canonicalizeAgentSessionIdentity } from './agent-session-claim-identity import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup' +import { OrchestrationError } from './orchestration/orchestration-error' import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv @@ -93,6 +94,15 @@ export class OrcaRuntimeWithGetAgentSessionExecutionNamespace extends OrcaRuntim // Legacy renderer sleep records are migration evidence, not host authority. throw new Error('agent_session_resume_not_authorized') } + if ( + request.agent === 'opencode' && + Object.values(request.launchPreferences ?? {}).some((value) => value !== undefined) + ) { + throw new OrchestrationError( + 'capability_unsupported', + 'OpenCode resume preferences are not verified by this execution host.' + ) + } if (!this.store) { throw new Error('runtime_unavailable') } diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 5a7a53267586..0193e610c0db 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -18,13 +18,10 @@ import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-termi import { resolveStartupShell, type AgentStartupShell } from '../../shared/tui-agent-startup-shell' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveConfiguredWorkerAgent } from './orchestration/configured-worker-agent-selector' -import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' -import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' -import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' -import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' -import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' import { parseWslUncPath } from '../../shared/wsl-paths' import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' +import { prepareOpenCodeModelStartupInputs } from '../opencode/opencode-model-startup-plan' +import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAdoptTerminalOrphansFromInventory { async getTerminalInteractiveWait( @@ -256,32 +253,26 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd : projectRuntime?.runtime.kind === 'wsl' ? { distro: projectRuntime.runtime.distro } : undefined - const platform = wsl ? 'linux' : process.platform - const command = - settings.agentCmdOverrides?.opencode || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, platform) - const env: Record = {} - for (const [key, value] of Object.entries({ - ...process.env, - ...resolveTuiAgentLaunchEnv('opencode', settings.agentDefaultEnv) - })) { - if (value !== undefined) { - env[key] = value - } + if (!path) { + return false + } + try { + await prepareOpenCodeModelStartupInputs({ + inputs: resolveAgentStartupPlanInputs({ + agent: 'opencode', + settings, + platform: wsl ? 'linux' : process.platform, + isRemote: false, + sessionOptions: { model: target.model } + }), + cwd: path, + isWsl: Boolean(wsl), + hostIdentity: this.getRuntimeId() + }) + return true + } catch { + return false } - applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode', isWsl: Boolean(wsl) }) - const capabilities = await probeOpenCodeLaunchCapabilities({ - command, - agent: 'opencode', - cwd: path, - wsl, - env, - hostIdentity: this.getRuntimeId() - }) - return ( - capabilities?.version === '1.18.30' && - (await probeOpenCodeModelAvailability({ command, model: target.model, cwd: path, wsl, env })) - ) } validateOrchestrationAgentLauncher(agent: TuiAgent): void { diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 7fef1bdf9db1..8adfa6f73dd6 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -19,7 +19,7 @@ import { terminalShellOverrideRefusal } from './terminal-shell-override-host-sup import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { resolveBareAgentLaunchCommand } from './runtime-agent-launch-resolution' -import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { buildExecutionHostAgentStartupPlan } from '../opencode/opencode-model-startup-plan' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { agentStartedTelemetry } from '../agent-launch/agent-started-telemetry' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -254,8 +254,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith return opts } - const startupPlan = buildAgentStartupPlan({ - ...resolveAgentStartupPlanInputs({ + const startupPlan = await buildExecutionHostAgentStartupPlan({ + inputs: resolveAgentStartupPlanInputs({ agent, settings, platform, @@ -266,7 +266,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith sessionOptions: this.toAgentSessionOptions(opts.launchPreferences) }), prompt: opts.startupPrompt ?? '', - allowEmptyPromptLaunch: true + cwd: resolveTerminalStartupCwd(workspace.path, opts.cwd) ?? workspace.path, + hostIdentity: this.runtimeId }) if (!startupPlan) { // Why: an explicit agent that yields no plan would otherwise spawn a bare diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index 950f7ca7ffb5..28a06b65351a 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' -import { probeOpenCodeModelAvailability } from '../opencode/opencode-model-availability' +import { + probeOpenCodeModelAvailability, + resolveOpenCodeDirectModelExecutable +} from '../opencode/opencode-model-availability' import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' @@ -12,7 +15,8 @@ vi.mock('../opencode/opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) vi.mock('../opencode/opencode-model-availability', () => ({ - probeOpenCodeModelAvailability: vi.fn() + probeOpenCodeModelAvailability: vi.fn(), + resolveOpenCodeDirectModelExecutable: vi.fn() })) vi.mock('../project-runtime-git-options', () => ({ resolveLocalProjectRuntimeForRepo: vi.fn() })) @@ -45,10 +49,22 @@ function probe( } describe('OpenCode worker model execution host', () => { + it('refuses resume preferences before any workspace or process effects', async () => { + await expect( + Reflect.apply(OrcaRuntimeService.prototype.ensureAgentSession, {}, [ + { + kind: 'explicit', + agent: 'opencode', + launchPreferences: { model: 'opencode/fledge-alpha-free' } + } + ]) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + }) beforeEach(() => { vi.clearAllMocks() vi.mocked(applyManagedDataAccountEnvironment).mockReset() vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) + vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/tmp/private-opencode') vi.mocked(resolveLocalProjectRuntimeForRepo).mockReturnValue(undefined) vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ version: '1.18.30', @@ -82,8 +98,7 @@ describe('OpenCode worker model execution host', () => { expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith(expect.objectContaining({ env })) expect(probeOpenCodeModelAvailability).toHaveBeenCalledWith(expect.objectContaining({ env })) expect(applyManagedDataAccountEnvironment).toHaveBeenCalledWith(expect.anything(), { - launchAgent: 'opencode', - isWsl: false + launchAgent: 'opencode' }) }) it('refuses unverified legacy versions even when they accept a model flag', async () => { @@ -128,10 +143,8 @@ describe('OpenCode worker model execution host', () => { ).toBe(false) expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() }) - it('probes the UNC workspace distro instead of native Windows', async () => { - expect(await probe(host({ path: '\\\\wsl.localhost\\Ubuntu\\home\\repo' }))).toBe(true) - expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith( - expect.objectContaining({ wsl: { distro: 'Ubuntu' } }) - ) + it('refuses WSL until the actual guest launch environment is supported', async () => { + expect(await probe(host({ path: '\\\\wsl.localhost\\Ubuntu\\home\\repo' }))).toBe(false) + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() }) }) From 19eafa609b8924685db499a317791d46b3d4fdd2 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:43:29 -0700 Subject: [PATCH 41/93] fix(opencode): refuse unsupported startup preferences before model probing --- src/main/opencode/opencode-model-startup-plan.test.ts | 10 ++++++++++ src/main/opencode/opencode-model-startup-plan.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index df3d805a0636..dcf66d3614f7 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -66,6 +66,16 @@ describe('execution-host OpenCode model startup', () => { }) }) + it('refuses unsupported effort preferences before probing or dropping them', async () => { + const options = scope() + options.inputs.sessionOptions = { model: preferred, effort: 'high' } + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported' + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() + }) + it('launches current v2 in isolation and keeps one-time model preferences out of resume state', async () => { const options = scope() const plan = await buildExecutionHostAgentStartupPlan(options) diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts index ee50b15a05c2..65cc660e3e99 100644 --- a/src/main/opencode/opencode-model-startup-plan.ts +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -34,6 +34,9 @@ export async function prepareOpenCodeModelStartupInputs(options: StartupScope) { return { inputs } } if ( + Object.entries(inputs.sessionOptions ?? {}).some( + ([key, value]) => key !== 'model' && value !== undefined + ) || inputs.isRemote || options.isWsl || inputs.platform !== process.platform || From 960bb84d74493b22d6c7e312073dca89831b0493 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:52:50 -0700 Subject: [PATCH 42/93] feat(opencode): probe execution-host CLI capabilities --- .../opencode/opencode-cli-version.test.ts | 51 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 68 +++++++++++++++++++ src/shared/opencode-cli-version.test.ts | 29 ++++++++ src/shared/opencode-cli-version.ts | 26 +++++++ 4 files changed, 174 insertions(+) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 000000000000..3639f02d145f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 000000000000..29e4775a0a02 --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env).sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 000000000000..9423f6e31754 --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 000000000000..874958e135b0 --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + } +} From 6b78423f6823897f36031d0518b0b54abe376e52 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:54:48 -0700 Subject: [PATCH 43/93] fix(opencode): select plugin default for execution host loader --- src/main/opencode/hook-plugin-module-contract.test.ts | 9 +++++++++ src/main/opencode/status-plugin-factory-source.ts | 7 ++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a0..f6d07361abe2 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde812..fd6c39bf510d 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), From 1b5f4c54aa699772f5e5501eee52807197657325 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:58:07 -0700 Subject: [PATCH 44/93] fix(opencode): limit prompt prefill capability to verified release --- src/shared/opencode-cli-version.test.ts | 5 +++++ src/shared/opencode-cli-version.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts index 9423f6e31754..cc9c5987f2e9 100644 --- a/src/shared/opencode-cli-version.test.ts +++ b/src/shared/opencode-cli-version.test.ts @@ -27,3 +27,8 @@ it('keeps future versions unknown instead of assuming a plugin loader or prompt promptMode: 'prefill' }) }) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts index 874958e135b0..2aa71b2c30f0 100644 --- a/src/shared/opencode-cli-version.ts +++ b/src/shared/opencode-cli-version.ts @@ -21,6 +21,6 @@ export function getOpenCodeCliCapabilities( return { version, pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', - promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' } } From b8cc6fca163024e2c754497f3138eef55fdc2c48 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:36:12 -0700 Subject: [PATCH 45/93] feat(opencode): probe launch capabilities on the execution host --- .../opencode/opencode-cli-version.test.ts | 14 +++ src/main/opencode/opencode-cli-version.ts | 29 +++--- .../opencode-launch-capabilities.test.ts | 96 +++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 82 ++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index 3639f02d145f..b0886517da9d 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -49,3 +49,17 @@ it('degrades timeouts and missing executables to unknown', async () => { (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version ).toBeNull() }) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 29e4775a0a02..00d5cefe6a3b 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -11,6 +11,7 @@ export type OpenCodeCliVersionProbe = { env: NodeJS.ProcessEnv cwd?: string hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> } const probes = new Map }>() @@ -48,19 +49,21 @@ async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, + wsl: { distro: 'Ubuntu' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + env: { OPENCODE_CONFIG_DIR: '/guest/config' } + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + program: 'opencode', + args: ['--version'], + env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 000000000000..1d63cf0ed446 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,82 @@ +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + const guestEnv: Record = {} + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'OPENCODE_DISABLE_AUTOUPDATE' + ]) { + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + const distro = options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: () => + runWslProcess({ + distro, + loginPath: 'preferred', + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} From 8c3d344f1b9201c76154875d113418fbd7768294 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:52:31 -0700 Subject: [PATCH 46/93] fix(opencode): select plugin loader for the launched host binary --- src/main/ipc/pty/ipc/spawn-options.ts | 12 ++++ src/main/ipc/pty/runtime/spawn-options.ts | 13 ++++ src/main/opencode/opencode-pty-launch.test.ts | 72 +++++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 33 +++++++++ src/main/providers/pty-spawn-result.ts | 3 + src/relay/pty-handler.ts | 29 +++++++- 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef16..699cddda7582 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -64,6 +65,17 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..f5ddf87646fd 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -34,6 +34,7 @@ import { } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -102,6 +103,18 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 000000000000..2a1cfb4e7cd4 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) + +describe('execution-host OpenCode launch preparation', () => { + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 000000000000..cfd0f815f143 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,33 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: { ...process.env, ...env } + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b66..4eb5dc45a60d 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fbad4a212a0d..b175d4b6496b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -3,6 +3,9 @@ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchroni import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -216,6 +219,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -275,6 +279,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -1905,6 +1910,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1933,6 +1941,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -1979,6 +1988,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { + spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi + if (isRelayWslShell(shell)) { + addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + } await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2079,6 +2104,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2157,7 +2183,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } From 944c72c2db23abcc0a2c9d0313c4a5bc24485327 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:05:36 -0700 Subject: [PATCH 47/93] fix(opencode): match WSL probe cwd and declared guest environment --- .../opencode-launch-capabilities.test.ts | 55 +++++++++++++++++-- .../opencode/opencode-launch-capabilities.ts | 30 +++++++--- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 81eb123cbce4..6e4a09130631 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -66,31 +66,76 @@ describe('OpenCode execution-host launch capability probe', () => { expect(mocks.probe).not.toHaveBeenCalled() }) - it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } await probeOpenCodeLaunchCapabilities({ command: 'opencode --standalone', - env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, - wsl: { distro: 'Ubuntu' }, + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, hostIdentity: 'host-a' }) expect(mocks.resolve).not.toHaveBeenCalled() const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } expect(options).toEqual( expect.objectContaining({ executablePath: 'opencode', hostIdentity: 'host-a:wsl:Ubuntu', - env: { OPENCODE_CONFIG_DIR: '/guest/config' } + cwd: '/home/user/repo', + env: guestEnv }) ) await options.execute() expect(mocks.wsl).toHaveBeenCalledWith({ distro: 'Ubuntu', loginPath: 'preferred', + cwd: '/home/user/repo', program: 'opencode', args: ['--version'], - env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + env: guestEnv, timeoutMs: 5000, maxOutputBytes: 4096 }) }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: {} }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) }) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 1d63cf0ed446..86ade3f8af5e 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -1,3 +1,4 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getFirstCommandToken, getCommandTokenPathBasename @@ -41,25 +42,36 @@ export async function probeOpenCodeLaunchCapabilities(options: { } if (options.wsl) { const guestEnv: Record = {} - for (const key of [ - 'OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_CONFIG_DIR', - 'OPENCODE_DISABLE_AUTOUPDATE' - ]) { - const value = options.env[key] - if (value !== undefined) { - guestEnv[key] = value + const wslEnv = options.env.WSLENV + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } } } - const distro = options.wsl.distro + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro return probeOpenCodeCliVersion({ executablePath: executable, env: guestEnv, + cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, execute: () => runWslProcess({ distro, loginPath: 'preferred', + cwd, program: executable, args: ['--version'], env: guestEnv, From 42788e7878161bca45d8be0008453a2b87b477e3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:39:15 -0700 Subject: [PATCH 48/93] fix(opencode): preserve launch environment deletion boundaries --- src/main/ipc/pty/ipc/spawn-options.ts | 1 + src/main/ipc/pty/runtime/spawn-options.ts | 1 + .../opencode-launch-capabilities.test.ts | 20 ++++ .../opencode/opencode-launch-capabilities.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 92 ++++++++++++++++++- src/main/opencode/opencode-pty-launch.ts | 16 +++- 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 699cddda7582..cf2d1c53b6ae 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -69,6 +69,7 @@ export async function buildPtyIpcSpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index f5ddf87646fd..0f4921cdf7dc 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -107,6 +107,7 @@ export async function buildRuntimePtySpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 6e4a09130631..9d15cefe80f2 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -13,9 +13,29 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) beforeEach(() => { vi.clearAllMocks() mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) }) describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) it('recognizes quoted executables and preserves explicit run commands', () => { expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 86ade3f8af5e..17a2a570872f 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -67,8 +67,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { env: guestEnv, cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, - execute: () => - runWslProcess({ + execute: async () => { + const result = await runWslProcess({ distro, loginPath: 'preferred', cwd, @@ -78,6 +78,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { timeoutMs: 5_000, maxOutputBytes: 4_096 }) + return result.environmentResolved ? result : { ...result, code: null } + } }) } const executablePath = options.resolveExecutable diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 2a1cfb4e7cd4..f7813ddc969e 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,13 +1,90 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + it.each(['1.1.23', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { @@ -21,6 +98,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode --prompt test', agent: 'opencode', env, + envToDelete: [], cwd: '/repo', isFreshLaunch: true }) @@ -39,7 +117,12 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) }) @@ -50,6 +133,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode', agent: 'opencode', env, + envToDelete: [], isFreshLaunch: true, wsl: { distro: 'Ubuntu' } }) @@ -64,7 +148,9 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index cfd0f815f143..5aa5adab0726 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,11 +1,13 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined agent?: TuiAgent env: Record | undefined + envToDelete: string[] cwd?: string connectionId?: string | null isFreshLaunch: boolean @@ -15,17 +17,29 @@ export async function prepareOpenCodePtyLaunch(options: { if (env) { delete env.ORCA_OPENCODE_PLUGIN_API } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } if (options.connectionId || !options.isFreshLaunch) { return env } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) const capabilities = await probeOpenCodeLaunchCapabilities({ ...options, - env: { ...process.env, ...env } + env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { return env } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } From 087aa5d9713c13d0c31a9bd87559f9d203f657f0 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:36:27 -0700 Subject: [PATCH 49/93] wip(opencode): authorize native startup prompt intent at execution owner --- .../server-startup-prompt-control.test.ts | 58 +++++ .../agent-hooks/server/server-lifecycle.ts | 31 ++- .../agent-hooks/server/server-listeners.ts | 5 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/opencode/opencode-pty-launch.test.ts | 90 +++++++- src/main/opencode/opencode-pty-launch.ts | 75 ++++++- .../opencode-startup-prompt-claims.test.ts | 49 +++++ .../opencode-startup-prompt-claims.ts | 70 ++++++ .../opencode/opencode-startup-prompt-owner.ts | 92 ++++++++ .../opencode-startup-prompt-source.test.ts | 207 ++++++++++++++++++ .../opencode-startup-prompt-source.ts | 111 ++++++++++ src/main/orcad/orcad-entry.ts | 8 +- src/main/runtime/orca-runtime-runtime-id.ts | 14 ++ src/main/runtime/terminal-run-facts.test.ts | 15 ++ src/main/runtime/terminal-run-facts.ts | 41 +++- src/main/startup/main-process-pty-startup.ts | 4 +- src/relay/pty-handler.ts | 17 ++ src/shared/opencode-startup-prompt.test.ts | 52 +++++ src/shared/opencode-startup-prompt.ts | 39 ++++ src/shared/tui-agent-startup.ts | 3 +- 25 files changed, 980 insertions(+), 26 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 000000000000..ebc96e63dea3 --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,58 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' + +describe('startup prompt control with status hooks disabled', () => { + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index e6e1bf80061d..49fb90321139 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -15,6 +15,7 @@ import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hoo import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { /** Start the loopback listener after hydration and spool replay have settled. */ @@ -22,10 +23,12 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,7 +40,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { + if (this.statusHooksEnabled && !this.ownerStateInitialized) { // Why: hydrate before binding the listener so an early hook POST runs against a populated map. if (this.lastStatusFilePath) { this.hydrateLastStatusFromDisk() @@ -84,6 +87,16 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -147,6 +160,11 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -187,7 +205,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -200,12 +220,17 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + if (this.statusHooksEnabled) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 961d3a3e9c87..ec03eefd98bc 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,6 +25,11 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index e5b9e4c13630..dfb35cae6be5 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 49bb8ade3ac9..2889ba293d97 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,6 +92,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11b..24df59c1201c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 0f4921cdf7dc..897fa37efa1b 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -103,7 +103,7 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) - ctx.env = await prepareOpenCodePtyLaunch({ + const openCodeLaunch = await prepareOpenCodePtyLaunch({ command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, @@ -115,6 +115,8 @@ export async function buildRuntimePtySpawnOptions( ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } : {}) }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index f7813ddc969e..27f327a4beac 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,6 +1,12 @@ +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' import { buildLocalPtySpawnEnvironment, enforceLocalPtySpawnEnvironmentOverrides @@ -28,6 +34,14 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { endpointFilePath: '/private/endpoint.env' } +})) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) @@ -35,13 +49,75 @@ beforeEach(() => probe.mockReset()) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -67,7 +143,7 @@ describe('execution-host OpenCode launch preparation', () => { vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] - const env = await prepareOpenCodePtyLaunch({ + const env = await prepare({ command: 'opencode', env: {}, envToDelete, @@ -94,7 +170,7 @@ describe('execution-host OpenCode launch preparation', () => { KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode --prompt test', agent: 'opencode', env, @@ -117,7 +193,7 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ + await prepare({ command: 'opencode', env: undefined, envToDelete: [], @@ -129,7 +205,7 @@ describe('execution-host OpenCode launch preparation', () => { it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) const env = { KEEP: '1' } - const result = await prepareOpenCodePtyLaunch({ + const result = await prepare({ command: 'opencode', agent: 'opencode', env, @@ -148,9 +224,7 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) - ).toEqual({}) + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 5aa5adab0726..314bec8770ed 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,5 +1,25 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' @@ -12,17 +32,29 @@ export async function prepareOpenCodePtyLaunch(options: { connectionId?: string | null isFreshLaunch: boolean wsl?: { distro?: string } -}): Promise | undefined> { +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] if (env) { delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } } // Providers merge their own ambient environment after this preparation. if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } if (options.connectionId || !options.isFreshLaunch) { - return env + return { env, command } } const probeEnv: Record = {} for (const [key, value] of Object.entries({ ...process.env, ...env })) { @@ -36,12 +68,47 @@ export async function prepareOpenCodePtyLaunch(options: { env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { - return env + return { env, command } } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } - return launchEnv + return { env: launchEnv, command } } diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 000000000000..1dae2785a51b --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 000000000000..9dabf9ce426e --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,70 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + readOwner: () => TerminalRunFacts | null + cleanup: () => void + expiry: ReturnType +} + +/** A one-use launch authorization; the execution owner supplies the process facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { digest, readOwner, expiresAt: now + 20000, cleanup, expiry }) + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + this.cancel(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + return false + } + const owner = pending.readOwner() + return owner?.freshSpawn === true && owner.firstUserInputAt === null + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 000000000000..72068fc81d40 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,92 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +const claims = new OpenCodeStartupPromptClaims() + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OrcaRuntimeService } + result: PtySpawnResult + provider: IPtyProvider + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OrcaRuntimeService | undefined + provider: IPtyProvider + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + return + } + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + let unsubscribe = () => {} + if ( + claims.register( + nonce, + digest, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 000000000000..09ea92d80015 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,207 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and dispatches only until OpenCode clears the exact draft', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 000000000000..0a140a9ad079 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,111 @@ +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint) { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + const response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return false; + return (await response.json()).allowed === true; +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => settle(); + // OpenCode clears the editor before awaiting session creation; never retry a restored failure. + const changed = () => { if (seen && editor.plainText !== (populated ? prompt : "")) settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + ({ createHash } = await import("node:crypto")); + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== (populated ? prompt : "")) return settle(); + seen = true; + const agents = ctx.data.location.agent.list(ctx.location); + const models = ctx.data.location.model.list(ctx.location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + if (!claimed) { + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint); + claiming = false; + if (!allowed) return settle(); + claimed = true; + } + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + if (!populated) { + populated = true; + editor.insertText(prompt); + if (memory.settled || !matches(editor)) return settle(); + } + ctx.keymap.dispatch("prompt.submit"); + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 854b4dfb723c..54eae48766a8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -194,9 +194,11 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dbdec24d0fc0..c856b91e3852 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -244,6 +244,20 @@ export class OrcaRuntimeWithRuntimeId { readonly terminalRunFacts = new TerminalRunFactsRegister() + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if ( + !pty || + pty.incarnationId !== incarnationId || + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + /** Both spawn-commit funnels report each committed process here, once. */ noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27c..1966a4059490 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4c..b1c029a767c1 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 648db87a3b61..5edfa51b110a 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -164,9 +164,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -175,6 +172,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b175d4b6496b..e68c12e2a8e8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -28,6 +28,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -1989,6 +1996,16 @@ export class PtyHandler { envToDelete ) delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ command, agent: launchAgent, diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 000000000000..e4aab26d0650 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain('run --prompt') + }) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 000000000000..1dbe9c2719d8 --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c41..ce8974a8cc33 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -123,7 +124,7 @@ export function buildAgentStartupPlan(args: { followupPrompt: null, launchConfig, ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) } } From d2a65a1e7b6df9589b2372589bf83273933a003d Mon Sep 17 00:00:00 2001 From: Ahmed Nagy Date: Fri, 2 Oct 2026 00:50:32 -0700 Subject: [PATCH 50/93] fix(opencode): atomically replace status plugin entrypoints --- src/main/codex-accounts/fs-utils.ts | 77 +---- src/main/opencode/hook-service.ts | 30 +- src/relay/opencode-canonical-config.ts | 10 +- src/relay/plugin-overlay.test.ts | 4 +- src/relay/plugin-overlay.ts | 11 +- .../opencode-plugin-atomic-write.test.ts | 279 ++++++++++++++++++ src/shared/opencode-plugin-atomic-write.ts | 102 +++++++ .../opencode-tui-plugin-install.test.ts | 59 ++++ src/shared/opencode-tui-plugin-install.ts | 29 +- src/shared/windows-retry-file-operations.ts | 69 +++++ 10 files changed, 560 insertions(+), 110 deletions(-) create mode 100644 src/shared/opencode-plugin-atomic-write.test.ts create mode 100644 src/shared/opencode-plugin-atomic-write.ts create mode 100644 src/shared/opencode-tui-plugin-install.test.ts create mode 100644 src/shared/windows-retry-file-operations.ts diff --git a/src/main/codex-accounts/fs-utils.ts b/src/main/codex-accounts/fs-utils.ts index 295dfac9b66b..0f96932ecb85 100644 --- a/src/main/codex-accounts/fs-utils.ts +++ b/src/main/codex-accounts/fs-utils.ts @@ -1,10 +1,15 @@ import { randomUUID } from 'node:crypto' -import { copyFileSync, existsSync, linkSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { rename } from 'node:fs/promises' +import { existsSync, linkSync, rmSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' -import { setTimeout } from 'node:timers/promises' import { grantDirAcl, isPermissionError } from '../win32-utils' import { nodeFileContentsEqualSync } from '../../shared/node-file-content-equality' +import { + copyFileWithWindowsRetry, + renameFileWithWindowsRetry, + renameFileWithWindowsRetryAsync +} from '../../shared/windows-retry-file-operations' + +export { copyFileWithWindowsRetry, renameFileWithWindowsRetry, renameFileWithWindowsRetryAsync } export function writeFileAtomically( targetPath: string, @@ -193,69 +198,3 @@ export function publishFileWithoutOverwrite(sourcePath: string, targetPath: stri throw error } } - -// Why: on Windows, file replacement and backup-copy operations can fail with -// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) -// holds the target file open. A short retry avoids transient failures without -// masking real permission errors. Total backoff (~750ms) covers typical AV -// scan windows seen in issue #1507. -export function renameFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => renameSync(source, target)) -} - -export async function renameFileWithWindowsRetryAsync( - source: string, - target: string, - isCurrent: () => boolean = () => true -): Promise { - for (let attempt = 1; ; attempt++) { - if (!isCurrent()) { - return false - } - try { - await rename(source, target) - return true - } catch (error) { - if (!shouldRetryFileOperation(error, attempt)) { - throw error - } - await setTimeout(attempt * 50) - } - } -} - -export function copyFileWithWindowsRetry(source: string, target: string): void { - runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) -} - -function runFileOperationWithWindowsRetry(operation: () => void): void { - for (let attempt = 1; ; attempt++) { - try { - operation() - return - } catch (error) { - if (shouldRetryFileOperation(error, attempt)) { - sleepSync(attempt * 50) - continue - } - throw error - } - } -} - -function shouldRetryFileOperation(error: unknown, attempt: number): boolean { - return ( - process.platform === 'win32' && - attempt < 6 && - error instanceof Error && - 'code' in error && - (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') - ) -} - -// Why: writeFileAtomically is a sync API called from sync paths, so the retry -// backoff must park the thread instead of burning CPU in a Date.now() loop. -const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) -function sleepSync(ms: number): void { - Atomics.wait(sleepBuffer, 0, 0, ms) -} diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 88007d05c88b..d7ce098bcec4 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,4 +1,3 @@ -import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -8,7 +7,6 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -35,6 +33,11 @@ import { export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from '../../shared/opencode-plugin-atomic-write' + const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' @@ -139,7 +142,7 @@ export class OpenCodeHookService { // Why: a TUI or service still loading this dir needs the TUI copy too, or the service keeps reporting under its starter pane. this.writeTuiPlugin(pluginsDir, source) if (installed !== source) { - writeFileAtomically(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { @@ -279,20 +282,15 @@ export class OpenCodeHookService { this.writeOverlayManifest(overlayDir, nextManifest) } - // Why: pre-write unlink guards against POSIX writeFileSync writing through a mirrored symlink and clobbering a same-named user plugin. + // Atomic replacement detaches mirrored links without touching user plugins. private writePluginIntoOverlay(overlayDir: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() - this.writeTuiPlugin(pluginsDir, source) + this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // File may not exist on a fresh overlay; a real failure surfaces on writeFileSync below. - } - writeFileSync(pluginPath, source) + writeOverlayOpenCodePluginAtomically(pluginPath, source) } } @@ -303,13 +301,17 @@ export class OpenCodeHookService { const source = this.pluginSource() this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } } - private writeTuiPlugin(pluginsDir: string, source: string): void { + private writeTuiPlugin( + pluginsDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' + ): void { if (this.installsTuiPlugin) { - writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source) + writeOpenCodeTuiPlugin(pluginsDir, this.pluginFileName, source, ownership) } } } diff --git a/src/relay/opencode-canonical-config.ts b/src/relay/opencode-canonical-config.ts index 8cdfa7d5536d..21ed93a3d769 100644 --- a/src/relay/opencode-canonical-config.ts +++ b/src/relay/opencode-canonical-config.ts @@ -1,7 +1,8 @@ -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { isInstalledOpenCodePluginCurrent } from '../shared/opencode-installed-plugin' +import { writeCanonicalOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { writeOpenCodeTuiPlugin } from '../shared/opencode-tui-plugin-install' const RELAY_HOOKS_DIR = '.orca-relay' @@ -26,12 +27,7 @@ export function installOpenCodePluginInCanonicalConfig( mkdirSync(join(configDir, 'plugins'), { recursive: true }) writeOpenCodeTuiPlugin(join(configDir, 'plugins'), pluginFileName, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { - try { - unlinkSync(pluginPath) - } catch { - // The file may not exist on the first install. - } - writeFileSync(pluginPath, source) + writeCanonicalOpenCodePluginAtomically(pluginPath, source) } return true } catch (err) { diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index 9129e202287b..6694627e9aeb 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -137,9 +137,9 @@ describe('PluginOverlayManager', () => { manager.setSources({ opencode2PluginSource: 'v2 plugin, next release' }) expect(manager.installOpenCodePlugin('opencode2', env)).toBe(true) - expect(lstatSync(pluginPath).isFile()).toBe(true) + expect(lstatSync(pluginPath).isSymbolicLink()).toBe(true) expect(readFileSync(pluginPath, 'utf8')).toBe('v2 plugin, next release') - expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin') + expect(readFileSync(targetPath, 'utf8')).toBe('v2 plugin, next release') } ) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 35455d06859e..6d78918c46e0 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -24,9 +24,9 @@ import { readdirSync, realpathSync, statSync, - unlinkSync, writeFileSync } from 'node:fs' +import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' @@ -214,13 +214,8 @@ export class PluginOverlayManager { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, pluginFileName) - writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source) - try { - unlinkSync(pluginPath) - } catch { - // Fresh overlay or no same-named stale symlink. - } - writeFileSync(pluginPath, source) + writeOpenCodeTuiPlugin(pluginsDir, pluginFileName, source, 'overlay') + writeOverlayOpenCodePluginAtomically(pluginPath, source) } /** Materialize the OpenCode plugin overlay for `id` (typically the diff --git a/src/shared/opencode-plugin-atomic-write.test.ts b/src/shared/opencode-plugin-atomic-write.test.ts new file mode 100644 index 000000000000..597304a3ff85 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync +} from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as retryOps from './windows-retry-file-operations' +import { + resolveCanonicalPluginWritePath, + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' + +type WriteFileSyncFn = typeof NodeFs.writeFileSync + +const { fsMock } = vi.hoisted(() => { + let realWrite: WriteFileSyncFn | undefined + return { + fsMock: { + writeFileSync: vi.fn(), + getRealWrite: (): WriteFileSyncFn | undefined => realWrite, + setRealWrite: (fn: WriteFileSyncFn): void => { + realWrite = fn + } + } + } +}) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + fsMock.setRealWrite(actual.writeFileSync) + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + actual.writeFileSync(...args) + ) + return { + ...actual, + writeFileSync: fsMock.writeFileSync + } +}) + +afterEach(() => { + const realWrite = fsMock.getRealWrite() + if (realWrite) { + fsMock.writeFileSync.mockImplementation((...args: Parameters) => + realWrite(...args) + ) + } + vi.restoreAllMocks() +}) + +describe('opencode-plugin-atomic-write', () => { + it('writes atomically via sibling temp file and never writes directly in place', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-atomic-write-')) + const pluginPath = join(testDir, 'plugins', 'orca-opencode-status.js') + const writtenPaths: string[] = [] + + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + fsMock.writeFileSync.mockImplementation( + ( + file: Parameters[0], + data: Parameters[1], + options: Parameters[2] + ) => { + writtenPaths.push(String(file)) + return realWrite(file, data, options) + } + ) + + try { + writeCanonicalOpenCodePluginAtomically(pluginPath, 'console.log("hello")') + expect(readFileSync(pluginPath, 'utf8')).toBe('console.log("hello")') + expect(writtenPaths).toHaveLength(1) + expect(writtenPaths[0]).not.toBe(pluginPath) + expect(writtenPaths[0]).toContain('.orca-opencode-status.js.') + expect(writtenPaths[0]).toContain('.tmp') + } finally { + rmSync(testDir, { recursive: true, force: true }) + } + }) + + it('preserves symlink and updates underlying target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-canonical-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const realFile = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(realFile, 'initial content', 'utf8') + symlinkSync(realFile, linkFile) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(realpathSync.native(realFile)) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'updated content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(readFileSync(realFile, 'utf8')).toBe('updated content') + expect(readFileSync(linkFile, 'utf8')).toBe('updated content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves dangling symlink and creates destination target in canonical mode', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-dangling-symlink-')) + const pluginsDir = join(testDir, 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const missingTarget = join(testDir, 'dotfiles-plugin.js') + const linkFile = join(pluginsDir, 'orca-opencode-status.js') + + symlinkSync(missingTarget, linkFile) + expect(existsSync(missingTarget)).toBe(false) + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + + expect(resolveCanonicalPluginWritePath(linkFile)).toBe(missingTarget) + + writeCanonicalOpenCodePluginAtomically(linkFile, 'dangling resolved content') + + expect(lstatSync(linkFile).isSymbolicLink()).toBe(true) + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('dangling resolved content') + expect(readFileSync(linkFile, 'utf8')).toBe('dangling resolved content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('preserves existing file permissions when updating plugin', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-permissions-')) + const pluginPath = join(testDir, 'status.js') + + realWrite(pluginPath, 'old content', { encoding: 'utf8', mode: 0o600 }) + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content') + expect(readFileSync(pluginPath, 'utf8')).toBe('new content') + expect(statSync(pluginPath).mode & 0o777).toBe(0o600) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('leaves existing target intact and cleans up temp file if rename fails', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-rename-fail-')) + const pluginPath = join(testDir, 'status.js') + const realWrite = fsMock.getRealWrite() + if (!realWrite) { + return + } + realWrite(pluginPath, 'original content', 'utf8') + + vi.spyOn(retryOps, 'renameFileWithWindowsRetry').mockImplementation(() => { + throw new Error('EPERM: file locked') + }) + + expect(() => writeCanonicalOpenCodePluginAtomically(pluginPath, 'new content')).toThrow( + 'EPERM: file locked' + ) + expect(readFileSync(pluginPath, 'utf8')).toBe('original content') + expect(readdirSync(testDir).filter((name) => name.endsWith('.tmp'))).toEqual([]) + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('replaces symlink in overlay mode without mutating user target file', () => { + if (process.platform === 'win32') { + return + } + const realWrite = fsMock.getRealWrite() + expect(realWrite).toBeDefined() + if (!realWrite) { + return + } + + const testDir = mkdtempSync(join(tmpdir(), 'opencode-overlay-symlink-')) + const pluginsDir = join(testDir, 'overlay', 'plugins') + mkdirSync(pluginsDir, { recursive: true }) + const userPlugin = join(testDir, 'user-plugin.js') + const overlayPlugin = join(pluginsDir, 'orca-opencode-status.js') + + realWrite(userPlugin, 'user original source', 'utf8') + symlinkSync(userPlugin, overlayPlugin) + + writeOverlayOpenCodePluginAtomically(overlayPlugin, 'orca status source') + + expect(lstatSync(overlayPlugin).isSymbolicLink()).toBe(false) + expect(lstatSync(overlayPlugin).isFile()).toBe(true) + expect(readFileSync(overlayPlugin, 'utf8')).toBe('orca status source') + expect(readFileSync(userPlugin, 'utf8')).toBe('user original source') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('creates missing directories automatically when writing plugin', () => { + const testDir = mkdtempSync(join(tmpdir(), 'opencode-nested-dir-')) + const deeplyNestedPlugin = join(testDir, 'nested', 'path', 'plugins', 'status.js') + + writeOverlayOpenCodePluginAtomically(deeplyNestedPlugin, 'content') + expect(existsSync(deeplyNestedPlugin)).toBe(true) + expect(readFileSync(deeplyNestedPlugin, 'utf8')).toBe('content') + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('resolves long dangling symlink chains and creates target without replacing intermediate links', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-long-symlinks-')) + const missingTarget = join(testDir, 'final-target.js') + let current = missingTarget + const links: string[] = [] + for (let i = 0; i < 15; i++) { + const nextLink = join(testDir, `link-${i}.js`) + symlinkSync(current, nextLink) + current = nextLink + links.push(nextLink) + } + + writeCanonicalOpenCodePluginAtomically(current, 'long chain content') + + expect(existsSync(missingTarget)).toBe(true) + expect(readFileSync(missingTarget, 'utf8')).toBe('long chain content') + for (const link of links) { + expect(lstatSync(link).isSymbolicLink()).toBe(true) + } + + rmSync(testDir, { recursive: true, force: true }) + }) + + it('throws on symlink loop without replacing intermediate symlinks', () => { + if (process.platform === 'win32') { + return + } + const testDir = mkdtempSync(join(tmpdir(), 'opencode-loop-symlinks-')) + const linkA = join(testDir, 'link-a.js') + const linkB = join(testDir, 'link-b.js') + symlinkSync(linkB, linkA) + symlinkSync(linkA, linkB) + + expect(() => writeCanonicalOpenCodePluginAtomically(linkA, 'loop content')).toThrow( + /ELOOP|symbolic link/i + ) + expect(lstatSync(linkA).isSymbolicLink()).toBe(true) + expect(lstatSync(linkB).isSymbolicLink()).toBe(true) + + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts new file mode 100644 index 000000000000..cd90bb2f3db3 --- /dev/null +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -0,0 +1,102 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + realpathSync, + statSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { basename, dirname, join, resolve } from 'node:path' +import { renameFileWithWindowsRetry } from './windows-retry-file-operations' + +function isEnoentError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +// Why: atomic rename on a dotfiles symlink replaces the link itself; resolving canonical target updates the real repo file. +export function resolveCanonicalPluginWritePath(pluginPath: string): string { + try { + const stat = lstatSync(pluginPath) + if (!stat.isSymbolicLink()) { + return pluginPath + } + } catch (error) { + if (isEnoentError(error)) { + return pluginPath + } + throw error + } + + // Why: realpathSync.native resolves canonical target when it exists; + // if target is missing (dangling symlink), follow readlinkSync chain so the + // target file is created at the intended destination and the symlink stays intact. + try { + return realpathSync.native(pluginPath) + } catch (error) { + if (!isEnoentError(error)) { + throw error + } + } + + const visited = new Set([pluginPath]) + let current = pluginPath + for (let depth = 0; depth < 40; depth++) { + try { + const link = readlinkSync(current) + current = resolve(dirname(current), link) + if (visited.has(current)) { + throw new Error(`Symbolic link loop detected resolving "${pluginPath}"`) + } + visited.add(current) + const nextStat = lstatSync(current) + if (!nextStat.isSymbolicLink()) { + return current + } + } catch (error) { + if (isEnoentError(error)) { + return current + } + throw error + } + } + throw new Error(`Too many levels of symbolic links resolving "${pluginPath}"`) +} + +// Why: write to sibling temp file and rename so concurrent reloads never observe a truncated or missing file. +function writeAtomicFile(targetPath: string, content: string): void { + const dir = dirname(targetPath) + mkdirSync(dir, { recursive: true }) + let existingMode: number | undefined + try { + existingMode = statSync(targetPath).mode + } catch { + // Target does not exist yet. + } + const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + renameFileWithWindowsRetry(tmpPath, targetPath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // Best effort cleanup. + } + } + } +} + +// Why: preserve dotfile symlinks at canonical paths by atomically replacing the real target file. +export function writeCanonicalOpenCodePluginAtomically(pluginPath: string, source: string): void { + const targetPath = resolveCanonicalPluginWritePath(pluginPath) + writeAtomicFile(targetPath, source) +} + +// Why: replace any mirrored symlink directly in the overlay without following it to the user's config. +export function writeOverlayOpenCodePluginAtomically(pluginPath: string, source: string): void { + writeAtomicFile(pluginPath, source) +} diff --git a/src/shared/opencode-tui-plugin-install.test.ts b/src/shared/opencode-tui-plugin-install.test.ts new file mode 100644 index 000000000000..18e08a4b5f55 --- /dev/null +++ b/src/shared/opencode-tui-plugin-install.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, it } from 'vitest' +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { openCodeTuiPluginDirName, writeOpenCodeTuiPlugin } from './opencode-tui-plugin-install' + +const roots: string[] = [] +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))) + +function linkedEntry(): { plugins: string; entry: string; target: string } { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + const plugins = join(root, 'plugins') + const entry = join(plugins, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const target = join(root, 'user-plugin.js') + mkdirSync(join(plugins, openCodeTuiPluginDirName('orca-status.js')), { recursive: true }) + writeFileSync(target, 'old') + symlinkSync(target, entry) + return { plugins, entry, target } +} + +it.skipIf(process.platform === 'win32')( + 'updates a canonical TUI symlink target and preserves the link', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'new') + expect(lstatSync(entry).isSymbolicLink()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('new') + } +) + +it.skipIf(process.platform === 'win32')( + 'detaches an overlay TUI link even when its user bytes match', + () => { + const { plugins, entry, target } = linkedEntry() + writeOpenCodeTuiPlugin(plugins, 'orca-status.js', 'old', 'overlay') + expect(lstatSync(entry).isFile()).toBe(true) + expect(readFileSync(target, 'utf8')).toBe('old') + } +) + +it('does not reload a current TUI plugin by changing its timestamp', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-tui-install-')) + roots.push(root) + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + const entry = join(root, openCodeTuiPluginDirName('orca-status.js'), 'tui.js') + const before = statSync(entry).mtimeMs + writeOpenCodeTuiPlugin(root, 'orca-status.js', 'current') + expect(statSync(entry).mtimeMs).toBe(before) +}) diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index db05aa8834d3..cad825212a8c 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -1,6 +1,13 @@ -import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync } from 'node:fs' +import { + writeCanonicalOpenCodePluginAtomically, + writeOverlayOpenCodePluginAtomically +} from './opencode-plugin-atomic-write' import { join } from 'node:path' -import { isInstalledOpenCodePluginCurrent } from './opencode-installed-plugin' +import { + isInstalledOpenCodePluginCurrent, + isOverlayOpenCodePluginCurrent +} from './opencode-installed-plugin' /** * Directory holding the TUI copy of a status plugin file. OpenCode 2 loads a @@ -19,18 +26,20 @@ export function openCodeTuiPluginDirName(pluginFileName: string): string { export function writeOpenCodeTuiPlugin( pluginsDir: string, pluginFileName: string, - source: string + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' ): void { const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) const entry = join(dir, 'tui.js') - if (isInstalledOpenCodePluginCurrent(entry, source)) { + const isCurrent = + ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent + if (isCurrent(entry, source)) { return } mkdirSync(dir, { recursive: true }) - try { - unlinkSync(entry) - } catch { - // First install, or nothing to replace. - } - writeFileSync(entry, source) + const write = + ownership === 'canonical' + ? writeCanonicalOpenCodePluginAtomically + : writeOverlayOpenCodePluginAtomically + write(entry, source) } diff --git a/src/shared/windows-retry-file-operations.ts b/src/shared/windows-retry-file-operations.ts new file mode 100644 index 000000000000..759bada16e9d --- /dev/null +++ b/src/shared/windows-retry-file-operations.ts @@ -0,0 +1,69 @@ +import { copyFileSync, renameSync } from 'node:fs' +import { rename } from 'node:fs/promises' +import { setTimeout } from 'node:timers/promises' + +// Why: on Windows, file replacement and backup-copy operations can fail with +// EPERM/EACCES/EBUSY if another process (antivirus, Claude CLI, Codex CLI) +// holds the target file open. A short retry avoids transient failures without +// masking real permission errors. Total backoff (~750ms) covers typical AV +// scan windows seen in issue #1507. +export function renameFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => renameSync(source, target)) +} + +export async function renameFileWithWindowsRetryAsync( + source: string, + target: string, + isCurrent: () => boolean = () => true +): Promise { + for (let attempt = 1; ; attempt++) { + if (!isCurrent()) { + return false + } + try { + await rename(source, target) + return true + } catch (error) { + if (!shouldRetryFileOperation(error, attempt)) { + throw error + } + await setTimeout(attempt * 50) + } + } +} + +export function copyFileWithWindowsRetry(source: string, target: string): void { + runFileOperationWithWindowsRetry(() => copyFileSync(source, target)) +} + +function runFileOperationWithWindowsRetry(operation: () => void): void { + for (let attempt = 1; ; attempt++) { + try { + operation() + return + } catch (error) { + if (shouldRetryFileOperation(error, attempt)) { + sleepSync(attempt * 50) + continue + } + throw error + } + } +} + +function shouldRetryFileOperation(error: unknown, attempt: number): boolean { + return ( + process.platform === 'win32' && + attempt < 6 && + error instanceof Error && + 'code' in error && + (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY') + ) +} + +// Why: writeFileAtomically is a sync API called from sync paths, so the retry +// backoff must park the thread instead of burning CPU in a Date.now() loop. +const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)) +function sleepSync(ms: number): void { + Atomics.wait(sleepBuffer, 0, 0, ms) +} From 8e16df5600cc6ebfc924d6f5a7a9f5e47f13d1d3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:08:14 -0700 Subject: [PATCH 51/93] fix(opencode): retain plugin permissions across restrictive umasks --- src/shared/opencode-plugin-atomic-write.ts | 6 ++- .../opencode-plugin-permissions.test.ts | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/shared/opencode-plugin-permissions.test.ts diff --git a/src/shared/opencode-plugin-atomic-write.ts b/src/shared/opencode-plugin-atomic-write.ts index cd90bb2f3db3..1ef27089bd00 100644 --- a/src/shared/opencode-plugin-atomic-write.ts +++ b/src/shared/opencode-plugin-atomic-write.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto' import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -71,13 +72,16 @@ function writeAtomicFile(targetPath: string, content: string): void { mkdirSync(dir, { recursive: true }) let existingMode: number | undefined try { - existingMode = statSync(targetPath).mode + existingMode = statSync(targetPath).mode & 0o777 } catch { // Target does not exist yet. } const tmpPath = join(dir, `.${basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`) try { writeFileSync(tmpPath, content, { encoding: 'utf8', mode: existingMode }) + if (existingMode !== undefined) { + chmodSync(tmpPath, existingMode) + } renameFileWithWindowsRetry(tmpPath, targetPath) } finally { if (existsSync(tmpPath)) { diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts new file mode 100644 index 000000000000..213f062e1454 --- /dev/null +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runProcess } from './child-process/run-process' +import { fileURLToPath } from 'node:url' +it.skipIf(process.platform === 'win32')( + 'retains existing permissions when the process umask is stricter', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) + const pluginPath = join(root, 'plugin.js') + const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + try { + const result = await runProcess({ + program: process.execPath, + args: [ + '--import', + 'tsx', + '--input-type=module', + '-e', + ` + import { chmodSync, statSync, writeFileSync } from 'node:fs'; + import { createRequire } from 'node:module'; + const { writeCanonicalOpenCodePluginAtomically } = createRequire(import.meta.url)(${JSON.stringify(modulePath)}); + const target = ${JSON.stringify(pluginPath)}; + writeFileSync(target, 'old'); + chmodSync(target, 0o664); + process.umask(0o027); + writeCanonicalOpenCodePluginAtomically(target, 'new'); + console.log(statSync(target).mode & 0o777); + ` + ], + timeoutMs: 10_000, + maxOutputBytes: 4_096 + }) + expect(result.code, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe(String(0o664)) + expect(readFileSync(pluginPath, 'utf8')).toBe('new') + } finally { + rmSync(root, { recursive: true, force: true }) + } + } +) From d9dc629f09e76e4d3bbf8b4ac4fb1ccf61446818 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:48:34 -0700 Subject: [PATCH 52/93] test(opencode): resolve permission fixture from primary cwd --- src/shared/opencode-plugin-permissions.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/shared/opencode-plugin-permissions.test.ts b/src/shared/opencode-plugin-permissions.test.ts index 213f062e1454..bdf6f5fa3b91 100644 --- a/src/shared/opencode-plugin-permissions.test.ts +++ b/src/shared/opencode-plugin-permissions.test.ts @@ -1,15 +1,14 @@ import { expect, it } from 'vitest' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { runProcess } from './child-process/run-process' -import { fileURLToPath } from 'node:url' it.skipIf(process.platform === 'win32')( 'retains existing permissions when the process umask is stricter', async () => { const root = mkdtempSync(join(tmpdir(), 'orca-plugin-umask-')) const pluginPath = join(root, 'plugin.js') - const modulePath = fileURLToPath(new URL('./opencode-plugin-atomic-write.ts', import.meta.url)) + const modulePath = resolve(process.cwd(), 'src/shared/opencode-plugin-atomic-write.ts') try { const result = await runProcess({ program: process.execPath, From a59e6a7e6ffa47f1c87ea10cdc55318885ca08b1 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:00:27 -0700 Subject: [PATCH 53/93] feat(opencode): install startup prompt plugin independently of status hooks --- src/main/opencode/hook-service.ts | 18 +++- .../opencode-startup-prompt-installer.test.ts | 72 ++++++++++++++ .../opencode-startup-prompt-installer.ts | 12 +++ src/relay/opencode-overlay-mirror.ts | 40 ++++++++ .../opencode-startup-prompt-install.test.ts | 86 +++++++++++++++++ src/relay/plugin-overlay.ts | 96 +++++++++---------- src/relay/relay-agent-hook-runtime.ts | 7 ++ src/relay/wsl-install-plugins-handler.ts | 24 +++++ src/shared/opencode-startup-prompt-install.ts | 17 ++++ src/shared/opencode-tui-plugin-install.ts | 16 +++- 10 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index d7ce098bcec4..3d90f2f89d5b 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -28,7 +28,8 @@ import { } from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, - writeOpenCodeTuiPlugin + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory } from '../../shared/opencode-tui-plugin-install' export { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } @@ -54,6 +55,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -73,6 +75,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -92,6 +95,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -134,6 +138,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -260,6 +267,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -288,6 +296,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source, 'overlay') + return + } this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { writeOverlayOpenCodePluginAtomically(pluginPath, source) @@ -299,6 +311,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source) + return + } this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeCanonicalOpenCodePluginAtomically(pluginPath, source) diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 000000000000..628bc464335f --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 000000000000..06c8d52e4c84 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,12 @@ +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 000000000000..0bd5e0b0f6c8 --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 000000000000..71cb373511e3 --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e0..08c9f772bed7 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -17,19 +23,11 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - realpathSync, - statSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +50,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +86,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +118,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +148,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +182,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +202,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +218,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +235,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 1dcda669f7d9..e7d37b6ed3e9 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -183,17 +183,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -210,8 +213,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea83..e66ff7d20a5e 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 000000000000..6427a9e98864 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index cad825212a8c..055aa3b7012e 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -29,7 +29,21 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent From 10a42d7fab2a55d09971a579c818b6c0ae8db6ca Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:35:33 -0700 Subject: [PATCH 54/93] fix(opencode): wait for admitted startup intent and preserve failed-launch briefs --- .../server-startup-prompt-control.test.ts | 54 ++++++++ .../agent-hooks/server/server-lifecycle.ts | 42 +++---- .../agent-hooks/server/server-listeners.ts | 5 +- src/main/agent-hooks/server/server-state.ts | 2 +- .../server/server-status-hook-lifecycle.ts | 52 ++++++++ src/main/ipc/pty/host-env/assembly.ts | 4 + .../opencode-hook-installation.test.ts | 22 ++++ src/main/opencode/hook-service.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 54 +++++++- src/main/opencode/opencode-pty-launch.ts | 18 ++- .../opencode-startup-prompt-claims.test.ts | 25 ++++ .../opencode-startup-prompt-claims.ts | 21 +++- .../opencode-startup-prompt-installer.test.ts | 27 +++- .../opencode-startup-prompt-installer.ts | 52 ++++++++ .../opencode-startup-prompt-owner.test.ts | 117 ++++++++++++++++++ .../opencode/opencode-startup-prompt-owner.ts | 39 ++++-- .../opencode-startup-prompt-source.test.ts | 49 ++++++++ .../opencode-startup-prompt-source.ts | 10 +- src/main/orcad/orcad-entry.ts | 8 ++ src/main/runtime/orca-runtime-runtime-id.ts | 9 +- .../startup/main-process-ready-foundation.ts | 2 + 21 files changed, 562 insertions(+), 56 deletions(-) create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts index ebc96e63dea3..1704b518c7ad 100644 --- a/src/main/agent-hooks/server-startup-prompt-control.test.ts +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -7,6 +7,60 @@ import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-fil import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' describe('startup prompt control with status hooks disabled', () => { + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 49fb90321139..858d6d0b860f 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,13 +11,12 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string @@ -26,6 +25,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } this.statusHooksEnabled = options?.statusHooksEnabled !== false @@ -40,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (this.statusHooksEnabled && !this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -89,7 +69,13 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const body = await readRequestBody(req) if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { res.writeHead(200, { 'content-type': 'application/json' }) - res.end(JSON.stringify({ allowed: this.onStartupPromptClaim?.(body) === true })) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) return } if (!this.statusHooksEnabled) { diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index ec03eefd98bc..dc89f4f18008 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -25,7 +25,10 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { - setStartupPromptClaimListener(listener: (body: unknown) => boolean, clear: () => void): void { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { this.onStartupPromptClaim = listener this.clearStartupPromptClaims = clear } diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 2889ba293d97..e9c4c9607e65 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -92,7 +92,7 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null - protected onStartupPromptClaim: ((body: unknown) => boolean) | null = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null protected clearStartupPromptClaims: (() => void) | null = null protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 000000000000..15a43182ab28 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7a..b22006f7b25b 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { OPENCODE_CONFIG_DIR_ENV_KEYS, isOpenCodeLegacySharedConfigDir @@ -341,5 +342,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb9427..dc62b1fad91a 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3d90f2f89d5b..3515f53ba3f8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -121,13 +121,15 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 27f327a4beac..d265ecc8747a 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -34,24 +34,38 @@ const plan: LocalPtyLaunchPlan = { launchWslDistro: null } -vi.mock('../agent-hooks/server', () => ({ - agentHookServer: { endpointFilePath: '/private/endpoint.env' } -})) +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) async function prepare(options: Parameters[0]) { return (await prepareOpenCodePtyLaunch(options)).env } const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) -beforeEach(() => probe.mockReset()) +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' @@ -74,6 +88,37 @@ describe('execution-host OpenCode launch preparation', () => { .command ).toBe("opencode --prompt 'task'") }) + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( 'gates native intent against the executing %s capability', async (version) => { @@ -83,6 +128,7 @@ describe('execution-host OpenCode launch preparation', () => { const env = await prepare({ command: 'opencode --prompt task', env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 314bec8770ed..3a547d35c0e8 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -22,6 +22,8 @@ const intentKeys = [ import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined @@ -70,11 +72,15 @@ export async function prepareOpenCodePtyLaunch(options: { if (!capabilities || capabilities.pluginApi === 'unknown') { return { env, command } } - const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi + } options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if ( capabilities.promptMode === 'prefill' && !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && requestedPrompt && body && command && @@ -92,13 +98,21 @@ export async function prepareOpenCodePtyLaunch(options: { !parsed.spans[last].divergesFromShell && !parsed.spans[last - 1].divergesFromShell ) { - command = command.slice(0, parsed.spans[last - 1].start).trimEnd() const endpoint = agentHookServer.endpointFilePath if (endpoint) { launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + installOpenCodeStartupPromptForLaunch(launchEnv, false) + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } for (const key of intentKeys) { if (launchEnv[key]) { options.envToDelete.splice(options.envToDelete.indexOf(key), 1) diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts index 1dae2785a51b..9d469b804731 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.test.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -35,6 +35,31 @@ describe('execution-owned startup prompt claims', () => { } }) + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + it('bounds pending claims and reclaims expired capacity without timers', () => { let now = 0 const claims = new OpenCodeStartupPromptClaims(() => now) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts index 9dabf9ce426e..02fb1e078c01 100644 --- a/src/main/opencode/opencode-startup-prompt-claims.ts +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -3,7 +3,7 @@ import type { TerminalRunFacts } from '../runtime/terminal-run-facts' type PromptClaim = { digest: string expiresAt: number - readOwner: () => TerminalRunFacts | null + readOwner: () => TerminalRunFacts | 'pending' | null cleanup: () => void expiry: ReturnType } @@ -35,6 +35,17 @@ export class OpenCodeStartupPromptClaims { return true } + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + cancel(nonce: string): void { const pending = this.pending.get(nonce) this.pending.delete(nonce) @@ -50,21 +61,25 @@ export class OpenCodeStartupPromptClaims { } } - claim(body: unknown): boolean { + claim(body: unknown): boolean | 'pending' { if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { return false } const pending = this.pending.get(body.nonce) - this.cancel(body.nonce) if ( !pending || pending.expiresAt <= this.now() || !('digest' in body) || body.digest !== pending.digest ) { + this.cancel(body.nonce) return false } const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + this.cancel(body.nonce) return owner?.freshSpawn === true && owner.firstUserInputAt === null } } diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts index 628bc464335f..6cc8d54824aa 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.test.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -3,7 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join } from 'node:path' import { setAppEnvironment } from '../../shared/app-environment' -import { createOpenCodeStartupPromptInstaller } from './opencode-startup-prompt-installer' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' let root: string let originalXdg: string | undefined @@ -30,6 +33,28 @@ afterEach(() => { rmSync(root, { recursive: true, force: true }) }) describe('OpenCode startup prompt installer', () => { + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') expect(service.buildPtyEnv('pane')).toEqual({}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts index 06c8d52e4c84..509d330d0f88 100644 --- a/src/main/opencode/opencode-startup-prompt-installer.ts +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -1,5 +1,15 @@ import { OpenCodeHookService } from './hook-service' import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { return new OpenCodeHookService({ @@ -10,3 +20,45 @@ export function createOpenCodeStartupPromptInstaller(source: () => string): Open tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY }) } + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = env.OPENCODE_CONFIG_DIR || resolveOpenCodeConfigDirectory(env) + const overlay = installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + if (restoreAfterShellStartup) { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 000000000000..eda73f48b553 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: () => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts index 72068fc81d40..be1dacba25db 100644 --- a/src/main/opencode/opencode-startup-prompt-owner.ts +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -9,15 +9,30 @@ import { } from '../../shared/opencode-startup-prompt' import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> const claims = new OpenCodeStartupPromptClaims() +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + export async function commitPtyWithOpenCodePromptIntent( context: { env?: Record spawnEnv?: Record - deps: { runtime?: OrcaRuntimeService } + deps: { runtime?: OpenCodePromptRuntime } result: PtySpawnResult - provider: IPtyProvider + provider: Pick args: { connectionId?: string | null } }, commit: () => Promise @@ -35,6 +50,12 @@ export async function commitPtyWithOpenCodePromptIntent | undefined result: PtySpawnResult - runtime: OrcaRuntimeService | undefined - provider: IPtyProvider + runtime: OpenCodePromptRuntime | undefined + provider: Pick connectionId?: string | null }): void { const { env, result, runtime, provider } = options @@ -62,17 +83,15 @@ export function bindOpenCodeStartupPromptOwner(options: { !digest || !launchToken ) { + if (nonce) { + claims.cancel(nonce) + } return } - agentHookServer.setStartupPromptClaimListener( - (body) => claims.claim(body), - () => claims.clear() - ) let unsubscribe = () => {} if ( - claims.register( + claims.admit( nonce, - digest, () => { if ( ptyOwnership.get(result.id) !== null || diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 09ea92d80015..279164adc087 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -21,6 +21,11 @@ let setup: PluginModule['default']['setup'] let claim: ReturnType class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } plainText = '' focused = true insertText(text: string) { @@ -79,6 +84,50 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries only explicitly pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('preserves typing that predates plugin setup without requesting owner permission', async () => { const f = fixture() f.editor.replace('early typing') diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 0a140a9ad079..d6df936f52db 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -21,7 +21,8 @@ async function claimStartupPrompt(nonce, digest, endpoint) { body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) }); if (!response.ok) return false; - return (await response.json()).allowed === true; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; } async function submitStartupPrompt(ctx) { const noop = async () => {}; @@ -41,6 +42,9 @@ async function submitStartupPrompt(ctx) { }); if (memory.settled) return noop; let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; const matches = (candidate) => typeof candidate?.plainText === "string" && createHash("sha256").update(candidate.plainText).digest("hex") === digest; const cleanup = () => { @@ -68,6 +72,7 @@ async function submitStartupPrompt(ctx) { try { if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } if (editor !== current) { if (seen) return settle(); editor?.off("line-info-change", changed); @@ -85,12 +90,13 @@ async function submitStartupPrompt(ctx) { claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint); claiming = false; + if (allowed === "pending") return; if (!allowed) return settle(); claimed = true; } if (disposed || memory.settled || Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || - !editor.focused || editor.plainText !== (populated ? prompt : "")) return settle(); + !editor.focused || !isComposer(editor) || editor.plainText !== (populated ? prompt : "")) return settle(); if (!populated) { populated = true; editor.insertText(prompt); diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 54eae48766a8..44460bbb0169 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -200,6 +200,14 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) + registerCleanup( + profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + ) + // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before // registerPtyHandlers so the IPC layer routes through the daemon from the first call. diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index c856b91e3852..83ade41d7cf2 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -246,9 +246,14 @@ export class OrcaRuntimeWithRuntimeId { readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } if ( - !pty || - pty.incarnationId !== incarnationId || pty.launchIncarnationId !== incarnationId || pty.launchToken !== launchToken || (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49a..c9ec3b30368d 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. From 6012c3770e9c2e917b8b81c9cae591a597a9e47c Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 05:07:59 -0700 Subject: [PATCH 55/93] fix(opencode): unsubscribe hook settings during async host shutdown --- src/main/orcad/orcad-entry.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 44460bbb0169..fb3cf97ad3c5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -200,13 +200,12 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) - registerCleanup( - profileStore.onSettingsChanged((updates, settings) => { - if ('agentStatusHooksEnabled' in updates) { - agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) - } - }) - ) + const removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + registerCleanup(async () => removeStatusHookSettingsListener()) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before From ab82bced1550c6badef0a54222b4b1b1feb3ef59 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:43:30 -0700 Subject: [PATCH 56/93] fix(opencode): confine overlay manifest cleanup to owned directories Co-authored-by: Adnan Khan --- src/main/opencode/hook-service.ts | 33 +++++------------ .../opencode-overlay-manifest.test.ts | 32 +++++++++++++++++ .../opencode/opencode-overlay-manifest.ts | 29 +++++++++++++++ src/main/pty/overlay-mirror.test.ts | 29 ++++++++++++++- src/main/pty/overlay-mirror.ts | 35 ++++++++++++++++++- 5 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 src/main/opencode/opencode-overlay-manifest.test.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.ts diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 3515f53ba3f8..599f2750d236 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -10,12 +10,17 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, @@ -41,12 +46,6 @@ import { const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} type OpenCodeHookVariant = { pluginFileName: string @@ -201,20 +200,6 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), @@ -224,7 +209,7 @@ export class OpenCodeHookService { private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) } const overlayPluginsDir = join(overlayDir, 'plugins') @@ -232,13 +217,13 @@ export class OpenCodeHookService { if (entryName === this.pluginFileName) { continue } - safeRemoveTree(join(overlayPluginsDir, entryName)) + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) } } // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 000000000000..7dc0404a53e6 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 000000000000..5abd0328f257 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d2264225..1a741552719c 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92c..6a35844f8717 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) From 6ade6421ebea5dab877690f02dd72a2cdf3b98d7 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 06:32:12 -0700 Subject: [PATCH 57/93] test: preserve typed calls in configured worker target checks Replace Reflect.apply with the existing typed prototype call pattern so the unchanged regression cases pass the anti-slop lint gate. --- ...hestration-configured-agent-target.test.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/orchestration-configured-agent-target.test.ts b/src/main/runtime/orchestration-configured-agent-target.test.ts index 4cdcc7dd7556..ff656e50d92e 100644 --- a/src/main/runtime/orchestration-configured-agent-target.test.ts +++ b/src/main/runtime/orchestration-configured-agent-target.test.ts @@ -23,11 +23,12 @@ it.each(['folder', 'ssh', 'wsl'] as const)( ) ) } - const result = await Reflect.apply( - OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, - context, - ['opencode-private', { worktree: 'id:workspace' }] - ) + const result = + await OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget.call( + context, + 'opencode-private', + { worktree: 'id:workspace' } + ) expect(result).toBe('opencode') expect(context.getAgentLaunchPlatformForWorkspace).toHaveBeenCalledWith(scope) expect(context.resolveOrchestrationAgentLauncher).toHaveBeenCalledWith( @@ -42,10 +43,10 @@ describe('canonical worker agent target', () => { it('keeps canonical selectors authoritative without probing another host', async () => { const resolve = vi.fn() expect( - await Reflect.apply( - OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, + await OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget.call( { resolveTerminalWorkspaceLaunchScope: resolve }, - ['opencode', { worktree: 'id:remote' }] + 'opencode', + { worktree: 'id:remote' } ) ).toBe('opencode') expect(resolve).not.toHaveBeenCalled() @@ -71,10 +72,10 @@ it('resolves local Windows shell settings rather than assuming PowerShell', asyn ) } expect( - await Reflect.apply( - OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget, + await OrcaRuntimeService.prototype.resolveOrchestrationAgentLauncherForTarget.call( context, - ['opencode-private', { worktree: 'id:workspace' }] + 'opencode-private', + { worktree: 'id:workspace' } ) ).toBe('opencode') expect(context.resolveOrchestrationAgentLauncher).toHaveBeenCalledWith( From d714b1645688183f171f61326090d722c7806f51 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 07:22:06 -0700 Subject: [PATCH 58/93] test: check typed model host calls and terminal delay state Replace Reflect dispatch and property reads with checked access and typed calls. Complete the existing resume request fixture with its required identity fields without changing the rejection boundary or assertions. --- .../orchestration-opencode-model-host.test.ts | 21 ++++++++++--------- .../terminal-pty-write-spy.unit.test.ts | 10 ++++++--- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index 28a06b65351a..1f74f6e79ae0 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -41,23 +41,25 @@ function probe( runtime: ReturnType, target = { worktree: 'id:folder', model: 'opencode/fledge-alpha-free' } ) { - return Reflect.apply( - OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport, + return OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport.call( runtime, - [target] + target ) } describe('OpenCode worker model execution host', () => { it('refuses resume preferences before any workspace or process effects', async () => { await expect( - Reflect.apply(OrcaRuntimeService.prototype.ensureAgentSession, {}, [ + OrcaRuntimeService.prototype.ensureAgentSession.call( + {}, { kind: 'explicit', + worktree: 'id:folder', agent: 'opencode', + providerSession: { key: 'session_id', id: 'ses_resume' }, launchPreferences: { model: 'opencode/fledge-alpha-free' } } - ]) + ) ).rejects.toMatchObject({ code: 'capability_unsupported' }) }) beforeEach(() => { @@ -112,11 +114,10 @@ describe('OpenCode worker model execution host', () => { }) it('refuses creation repo catalogs before the actual destination exists', async () => { expect( - await Reflect.apply( - OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport, - host(), - [{ repo: 'id:repo', model: 'opencode/fledge-alpha-free' }] - ) + await OrcaRuntimeService.prototype.probeOrchestrationOpenCodeModelLaunchSupport.call(host(), { + repo: 'id:repo', + model: 'opencode/fledge-alpha-free' + }) ).toBe(false) expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() diff --git a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts index 5c9226080088..2ef600f8c6f2 100644 --- a/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts +++ b/tests/e2e/helpers/terminal-pty-write-spy.unit.test.ts @@ -7,12 +7,16 @@ it('sets and clears paste backpressure using the main-process evaluate argument' vi.stubGlobal('__terminalPtyWriteDelayMs', 0) const evaluate = vi.fn() evaluate.mockImplementation( - (callback: (electron: object, argument: number) => void, delay: number) => callback({}, delay) + (callback: (electron: unknown, argument: number) => void, delay: number) => callback({}, delay) ) const app = { evaluate } await setTerminalPtyWriteDelay(app, 35) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(35) + expect( + '__terminalPtyWriteDelayMs' in globalThis ? globalThis.__terminalPtyWriteDelayMs : undefined + ).toBe(35) await setTerminalPtyWriteDelay(app, 0) - expect(Reflect.get(globalThis, '__terminalPtyWriteDelayMs')).toBe(0) + expect( + '__terminalPtyWriteDelayMs' in globalThis ? globalThis.__terminalPtyWriteDelayMs : undefined + ).toBe(0) }) From cfae79fa89c62b50f4e1ba8f4ea3a670812b82b0 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:28:41 -0700 Subject: [PATCH 59/93] test(readiness): census recorded OpenCode composer boots --- ...encode-1-18-32-timed-boot-hidden-pane@agent.json | 13 +++++++++++++ ...code-1-18-32-timed-boot-hidden-pane@unknown.json | 13 +++++++++++++ ...ipt--opencode-1-18-32-timed-boot-slow@agent.json | 13 +++++++++++++ ...t--opencode-1-18-32-timed-boot-slow@unknown.json | 13 +++++++++++++ ...--opencode-1-18-32-timed-first-launch@agent.json | 13 +++++++++++++ ...opencode-1-18-32-timed-first-launch@unknown.json | 13 +++++++++++++ ...pencode-2-0-18-timed-boot-hidden-pane@agent.json | 13 +++++++++++++ ...ncode-2-0-18-timed-boot-hidden-pane@unknown.json | 13 +++++++++++++ .../runtime/readiness-census-transcript-catalog.ts | 2 ++ src/main/runtime/readiness-census.test.ts | 12 ++++++++++++ 10 files changed, 118 insertions(+) create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json new file mode 100644 index 000000000000..bc852df43bfe --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-31: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "32-36: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-31: verdict=pending:closed wait=pending", + "32-36: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json new file mode 100644 index 000000000000..e81353565e88 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-31: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "32-36: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-31: verdict=pending:open wait=ready@poll", + "32-36: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json new file mode 100644 index 000000000000..817fbfc423d1 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-26: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:closed wait=pending", + "22-26: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json new file mode 100644 index 000000000000..3d6fbbc85f3a --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "22-26: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:open wait=ready@poll", + "22-26: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json new file mode 100644 index 000000000000..4935173109db --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-32: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "33-37: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-32: verdict=pending:closed wait=pending", + "33-37: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json new file mode 100644 index 000000000000..68787dfeac61 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-32: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "33-37: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-32: verdict=pending:open wait=ready@poll", + "33-37: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json new file mode 100644 index 000000000000..1796a3a6e6be --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-22: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "23-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-22: verdict=pending:closed wait=pending", + "23-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json new file mode 100644 index 000000000000..7cdd73d939ee --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-22: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "23-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-22: verdict=pending:open wait=ready@poll", + "23-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 3ba056ef1e60..7f32d92508c2 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -43,6 +43,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }], ['muse-', { agent: 'muse', foregroundProcess: 'muse' }], ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], + ['opencode-2-', { agent: 'opencode2', foregroundProcess: 'opencode' }], + ['opencode-1-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], ['qoder-', { agent: 'qoder', foregroundProcess: 'qodercli' }], ['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }] diff --git a/src/main/runtime/readiness-census.test.ts b/src/main/runtime/readiness-census.test.ts index 1aab4d59b6cf..e757acdcb7e8 100644 --- a/src/main/runtime/readiness-census.test.ts +++ b/src/main/runtime/readiness-census.test.ts @@ -23,14 +23,26 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { describeCensusDiff, runLengthDecode, runLengthEncode } from './readiness-census-baseline' import { CENSUS_AGENTS } from './readiness-census-synthetic-matrix' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { CENSUS_PANES, + CENSUS_TRANSCRIPTS, CENSUS_SHARD_COUNT, censusPaneSubject, censusShard } from './readiness-census-transcript-catalog' describe('readiness census coverage', () => { + it('assigns every cited composer recording to its launch agent', () => { + for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG)) { + for (const name of config.composerReadyCaptures ?? []) { + const transcript = CENSUS_TRANSCRIPTS.find((recording) => recording.name === name) + expect(transcript, name).toBeDefined() + expect(transcript?.agent, name).toBe(agent) + } + } + }) + it('replays every pane in exactly one shard', () => { const sharded = Array.from({ length: CENSUS_SHARD_COUNT }, (_, index) => censusShard(index + 1) From 566887748300a3b7a251ecab7cd0acf9e34e5878 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:29:27 -0700 Subject: [PATCH 60/93] fix(opencode): reject redirected overlay parents before cleanup --- .../hook-service-overlay-confinement.test.ts | 98 +++++++++++++++++++ src/main/opencode/hook-service.ts | 11 ++- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/main/opencode/hook-service-overlay-confinement.test.ts diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 000000000000..46a5bee58843 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 599f2750d236..24cdef073669 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -10,7 +11,7 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, @@ -125,7 +126,13 @@ export class OpenCodeHookService { } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } if (existsSync(existingConfigDir)) { this.mirrorUserConfig(existingConfigDir, overlayDir) } From 3fc2e912e9495a251fbedbc84a95d3d77014a685 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:30:21 -0700 Subject: [PATCH 61/93] fix(orcad): retain runtime cleanup when subscribing to hook settings --- src/main/ipc/pty-ipc-mock-registry.ts | 3 +++ src/main/orcad/orcad-entry.ts | 5 ++-- src/main/orcad/orcad-push-startup.test.ts | 29 +++++++++++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d56..4eba8d0b1641 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index fb3cf97ad3c5..e0af0ad74481 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -200,12 +202,11 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) - const removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { if ('agentStatusHooksEnabled' in updates) { agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) } }) - registerCleanup(async () => removeStatusHookSettingsListener()) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 21ad507c0164..91f5bac8cff3 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -40,6 +44,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -120,6 +125,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -172,6 +181,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -185,3 +199,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) From 18b4892711c7463b5a36fad087b83bf43a9e7893 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:31:18 -0700 Subject: [PATCH 62/93] refactor(launch): extract OpenCode config and attachment authority --- src/main/ipc/pty/host-env/assembly.ts | 67 ++------------- src/main/ipc/pty/host-env/opencode-config.ts | 84 +++++++++++++++++++ .../federation-attachment-authority.ts | 35 ++++++++ .../orchestration/federation/federation.ts | 19 ++--- 4 files changed, 132 insertions(+), 73 deletions(-) create mode 100644 src/main/ipc/pty/host-env/opencode-config.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index b22006f7b25b..88b5df165d17 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,16 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -29,12 +23,12 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -50,31 +44,8 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -109,37 +80,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 000000000000..88dafabc88ea --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + env.ORCA_OPENCODE_AGENT = agent + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!options.isWsl) { + Object.assign(env, service.buildPtyEnv(id, config.directory)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts new file mode 100644 index 000000000000..d36fbd98a69a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts @@ -0,0 +1,35 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { FederationEffect } from './federation-effects' +import type { WorkerSetupReceipt } from '../worker/worker-topology' + +export function prepareFederatedAttachmentAuthority(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + terminalHandle: string + worktreeId: string + setup: WorkerSetupReceipt + effects: FederationEffect[] + reusesTerminal: boolean +}): void { + const { runtime, terminalHandle } = args + const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) + const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) + const processIncarnation = + authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle) + if (!paneKey || !processIncarnation) { + throw new Error('stable_pane_required') + } + args.db.prepareRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + paneKey, + processIncarnation, + worktreeId: args.worktreeId, + terminalHandle, + setupState: args.setup.state, + effects: args.effects, + hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, + terminalOwnership: args.reusesTerminal ? 'external' : 'created' + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 6bd678e2f6d9..ffb7a5cfe2c0 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -28,6 +28,7 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' +import { prepareFederatedAttachmentAuthority } from './federation-attachment-authority' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ @@ -234,23 +235,15 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ : `Agent did not become ready (${wait.status}).` ) } - const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) - const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) - const processIncarnation = - authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle) - if (!paneKey || !processIncarnation) { - throw new Error('stable_pane_required') - } - db.prepareRemoteAttachmentAuthority({ + prepareFederatedAttachmentAuthority({ + runtime, + db, dispatchId: params.dispatchId, - paneKey, - processIncarnation, worktreeId: worktree.id, terminalHandle, - setupState: setup.state, + setup, effects, - hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, - terminalOwnership: params.terminal ? 'external' : 'created' + reusesTerminal: Boolean(params.terminal) }) failedStage = 'dispatch_input' const prompt = await runtime.sendTerminalAgentPrompt( From 1679ae3bb610151a782bf6eec0dbb58bb085aa14 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:33:05 -0700 Subject: [PATCH 63/93] fix(opencode): retain host version selection across relay restarts --- .../opencode/hook-plugin-dispose-host.test.ts | 2 + .../hook-plugin-opencode2-setup.test.ts | 2 + src/main/opencode/hook-service.test.ts | 3 + .../opencode/opencode-cli-version.test.ts | 66 ++++++++ src/main/opencode/opencode-cli-version.ts | 18 ++- src/relay/opencode-plugin-selection.ts | 39 +++++ src/relay/pty-handler-opencode-revive.test.ts | 142 ++++++++++++++++++ src/relay/pty-handler.ts | 23 ++- 8 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 src/relay/opencode-plugin-selection.ts create mode 100644 src/relay/pty-handler-opencode-revive.test.ts diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts index 179714d8c831..52e152753a4f 100644 --- a/src/main/opencode/hook-plugin-dispose-host.test.ts +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -34,6 +34,7 @@ type PluginModule = { const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', } process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 1ada5ea1a53d..0fa86f3d70d8 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag savedEnv[key] = process.env[key] } process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index ff124eb0b951..2c904c948a24 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,6 +32,7 @@ import { import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' beforeEach(() => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') setAppEnvironment({ getPath: getPathMock, getAppPath: () => process.cwd(), @@ -43,6 +44,8 @@ beforeEach(() => { }) }) +afterEach(() => vi.unstubAllEnvs()) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode id safety guard', () => { diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index b0886517da9d..23f6b9eea3fe 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -63,3 +63,69 @@ it('shares a bounded execution-host callback without probing the native machine' expect(execute).toHaveBeenCalledTimes(1) expect(runProcess).not.toHaveBeenCalled() }) + +it('shares probes across pane identities while preserving launch-affecting environment keys', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.0.16', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/pane-cache/opencode', + cwd: '/workspace', + env: { + PATH: '/runtime', + OPENCODE_CONFIG_DIR: '/config', + ORCA_PANE_KEY: 'pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_TERMINAL_HANDLE: 'term-1', + ORCA_WORKTREE_ID: 'workspace-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_PANE: 'pane-1', + ORCA_AGENT_LAUNCH: 'launch-1' + }, + hostIdentity: 'pane-cache-host' + } + await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion({ + ...options, + env: { + ...options.env, + ORCA_PANE_KEY: 'pane-2', + ORCA_TAB_ID: 'tab-2', + ORCA_TERMINAL_HANDLE: 'term-2', + ORCA_WORKTREE_ID: 'workspace-2', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-2', + ORCA_AGENT_PANE: 'pane-2', + ORCA_AGENT_LAUNCH: 'launch-2' + } + }) + ]) + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } }) + await probeOpenCodeCliVersion({ + ...options, + env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' } + }) + await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' }) + expect(runProcess).toHaveBeenCalledTimes(4) +}) + +it('separates native and host-callback backends even with the same host identity', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.32', + stderr: '', + timedOut: false + }) + const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' } + expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1') + const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' })) + expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2') + expect(execute).toHaveBeenCalledOnce() + expect(runProcess).toHaveBeenCalledOnce() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 00d5cefe6a3b..06804bad2eac 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -1,5 +1,9 @@ import { createHash } from 'node:crypto' import path from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' import { runProcess } from '../../shared/child-process/run-process' import { getOpenCodeCliCapabilities, @@ -17,15 +21,27 @@ export type OpenCodeCliVersionProbe = { const probes = new Map }>() const CACHE_TTL_MS = 60_000 const MAX_CACHED_PROBES = 128 +const PANE_IDENTITY_ENV_KEYS = new Set([ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV +]) export function probeOpenCodeCliVersion( options: OpenCodeCliVersionProbe ): Promise { const identity = JSON.stringify([ + options.execute ? 'host-callback' : 'native-process', options.hostIdentity ?? process.platform, options.executablePath, options.cwd, - Object.entries(options.env).sort(([left], [right]) => left.localeCompare(right)) + Object.entries(options.env) + .filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) ]) const key = createHash('sha256').update(identity).digest('hex') const cached = probes.get(key) diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts new file mode 100644 index 000000000000..38c7934cfd64 --- /dev/null +++ b/src/relay/opencode-plugin-selection.ts @@ -0,0 +1,39 @@ +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../shared/opencode-cli-version' +import { addWslEnvKeys } from '../shared/wsl-env' + +export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + typeof value.version !== 'string' + ) { + return undefined + } + const capabilities = getOpenCodeCliCapabilities(value.version) + return capabilities.pluginApi === 'unknown' ? undefined : capabilities +} + +export function applyOpenCodePluginSelection( + env: Record, + envToDelete: string[], + capabilities: OpenCodeCliCapabilities | null | undefined, + wsl: boolean +): void { + delete env.ORCA_OPENCODE_PLUGIN_API + if (!capabilities || capabilities.pluginApi === 'unknown') { + return + } + env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi + for (let index = envToDelete.length - 1; index >= 0; index -= 1) { + if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { + envToDelete.splice(index, 1) + } + } + if (wsl) { + addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) + } +} diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts new file mode 100644 index 000000000000..1c73f8be1fe6 --- /dev/null +++ b/src/relay/pty-handler-opencode-revive.test.ts @@ -0,0 +1,142 @@ +import './mock-descendant-sweep' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { applyOpenCodePluginSelection } from './opencode-plugin-selection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createMockDispatcher, + createTestPtyHandler, + endPtyHandlerTest, + testPtyId, + type MockDispatcher +} from './pty-handler-test-harness' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + probe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: mocks.probe +})) + +let dispatcher: MockDispatcher +let handler: PtyHandler +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) + mocks.probe.mockReset() +}) + +afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) + +function spawnedEnvironment(): Record { + const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] + if ( + !options || + typeof options !== 'object' || + !('env' in options) || + !options.env || + typeof options.env !== 'object' + ) { + throw new Error('Expected a PTY spawn environment') + } + return Object.fromEntries(Object.entries(options.env)) +} + +async function serialize(): Promise { + const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) + if (typeof state !== 'string') { + throw new Error('Expected serialized PTY state') + } + return state +} + +async function restartHandler(): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + mocks.mockPtySpawn.mockClear() +} + +it.each(['1.18.32', '2.0.16'])( + 'preserves host-selected plugin exports through two relay revives on %s', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + mocks.probe.mockResolvedValue(capabilities) + await dispatcher.callRequest('pty.spawn', { + cwd: tmpdir(), + command: 'opencode --standalone', + launchAgent: 'opencode', + env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, + envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const state = await serialize() + expect(JSON.parse(state)).toMatchObject([ + { openCodeCapabilities: capabilities, envToDelete: [] } + ]) + + await restartHandler() + await dispatcher.callRequest('pty.revive', { state }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const second = await serialize() + await restartHandler() + await dispatcher.callRequest('pty.revive', { state: second }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + expect(mocks.probe).toHaveBeenCalledOnce() + } +) + +it.each([ + undefined, + { version: 'garbage', pluginApi: 'v1' }, + { version: '3.0.0', pluginApi: 'v1' }, + { version: 2, pluginApi: 'v1' } +])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { + handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) + await dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { + id: testPtyId(1), + pid: process.pid, + cols: 80, + rows: 24, + cwd: tmpdir(), + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) + } + ]) + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() + expect(mocks.probe).not.toHaveBeenCalled() +}) + +it('carries a selected API into WSL without dropping other forwarded keys', () => { + const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } + const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] + applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) + expect(env).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' + }) + expect(envToDelete).toEqual(['OTHER_ENV']) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index e68c12e2a8e8..ec670ff4ba66 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -5,6 +5,10 @@ import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-sign import { FreebuffStatusProjection } from './freebuff-status-projection' import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { + applyOpenCodePluginSelection, + restoreOpenCodeCapabilities +} from './opencode-plugin-selection' import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' @@ -458,6 +462,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -2015,12 +2020,12 @@ export class PtyHandler { resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) }) - if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { - spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi - if (isRelayWslShell(shell)) { - addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) - } - } + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -3090,6 +3095,9 @@ export class PtyHandler { worktreeId: managed.worktreeId, ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, + ...(managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), // Why serialized: revive re-spawns the shell, and without these a WSL @@ -3184,6 +3192,8 @@ export class PtyHandler { { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) + const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) + applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) if ( historyIsolationEnabled && entry.worktreeId && @@ -3259,6 +3269,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, // Why re-stored: a revived pane can be serialized again, and losing the From 23fc08b4e939169f0f6bc80d6e2a92e88699258b Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 20:56:53 -0700 Subject: [PATCH 64/93] fix(opencode): pass run prompts as positional messages Preserve run flags and use the existing shell quoting and run-command detector to append the initial message after --, reusing an existing separator. TUI launches retain their version-selected prompt transport and draft behavior. Original run-order work: @coelho-doti (#13065, tracked in #17551). --- src/shared/flag-prompt-startup.ts | 50 +++++++++++++++++++ src/shared/opencode-startup-prompt.test.ts | 57 +++++++++++++++++++++- src/shared/tui-agent-startup.ts | 21 ++++---- 3 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 src/shared/flag-prompt-startup.ts diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts new file mode 100644 index 000000000000..28dd70cf0bda --- /dev/null +++ b/src/shared/flag-prompt-startup.ts @@ -0,0 +1,50 @@ +import type { AgentStartupPlan } from './tui-agent-startup' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' + +export function appliedSessionOptionProps(values: Record) { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} +} + +export function buildFlagPromptStartupPlan(args: { + agent: TuiAgent + launchCommand: string + quotedPrompt: string + prompt: string + shell: AgentStartupShell + launchConfig: SleepingAgentLaunchConfig + sessionOptions: Record + agentEnv: Record | null | undefined +}): AgentStartupPlan { + const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) + const isOpenCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && + parsed.ok && + isOpenCodeRunCommand(parsed.tokens) + // OpenCode run takes a positional message; --prompt belongs to its TUI. + const promptSuffix = isOpenCodeRun + ? parsed.tokens.includes('--') + ? ` ${args.quotedPrompt}` + : ` -- ${args.quotedPrompt}` + : ` --prompt ${args.quotedPrompt}` + return { + agent: args.agent, + launchCommand: `${args.launchCommand}${promptSuffix}`, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig: args.launchConfig, + ...appliedSessionOptionProps(args.sessionOptions), + ...openCodeStartupPromptEnv( + args.agent, + args.launchCommand, + args.shell, + args.prompt, + args.agentEnv + ) + } +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index e4aab26d0650..d438028ba968 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -6,6 +6,7 @@ import { OPENCODE_STARTUP_PROMPT_BODY_ENV, OPENCODE_STARTUP_PROMPT_SHELL_ENV } from './opencode-startup-prompt' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' describe('native OpenCode startup submission intent', () => { it('binds the exact trimmed native prompt to host-selectable transport', () => { @@ -33,9 +34,63 @@ describe('native OpenCode startup submission intent', () => { platform: 'linux' }) expect(plan?.env).toBeUndefined() - expect(plan?.launchCommand).toContain('run --prompt') + expect(plan?.launchCommand).toContain("run -- 'task'") }) + it.each(['posix', 'powershell', 'cmd'] as const)( + 'keeps run and its flags before a positional message in %s', + (shell) => { + for (const agent of ['opencode', 'opencode2'] as const) { + const prompt = '--literal task with unicode é' + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan).not.toBeNull() + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual([ + 'opencode', + '--log-level', + 'debug', + 'run', + '--standalone', + '--', + prompt + ]) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'reuses an existing run message separator in %s', + (shell) => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '--literal task', + cmdOverrides: { opencode: 'opencode run --standalone --' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell + }) + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual(['opencode', 'run', '--standalone', '--', '--literal task']) + expect(plan?.env).toBeUndefined() + } + ) + it('never gives an editable draft or empty launch an automatic submission intent', () => { const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } expect( diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index ce8974a8cc33..8c56c1d9d95f 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,7 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' -import { openCodeStartupPromptEnv } from './opencode-startup-prompt' +import { appliedSessionOptionProps, buildFlagPromptStartupPlan } from './flag-prompt-startup' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -36,10 +36,6 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps(values: Record) { - return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -117,15 +113,16 @@ export function buildAgentStartupPlan(args: { } if (config.promptInjectionMode === 'flag-prompt') { - return { + return buildFlagPromptStartupPlan({ agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null, + launchCommand, + quotedPrompt, + prompt: trimmedPrompt, + shell, launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...openCodeStartupPromptEnv(agent, launchCommand, shell, trimmedPrompt, args.agentEnv) - } + sessionOptions: baseCommand.appliedSessionOptions, + agentEnv: args.agentEnv + }) } if (config.promptInjectionMode === 'hermes-query') { From a44345ce496482c2d9d5bcaf7bb890faa3d4d52e Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 21:57:34 -0700 Subject: [PATCH 65/93] fix(opencode): keep wrapped run tasks positional Recognize supported environment prefixes and PowerShell call operators without mistaking prompt arguments for executables. Keep environment and run separators separate, preserve the task text and exclude run commands from native submission. Source-parent: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Related-to: stablya/orca#17551 Credits: @coelho-doti (stablya/orca#13065) --- src/main/opencode/opencode-pty-launch.ts | 2 +- src/shared/flag-prompt-startup.ts | 14 ++-- src/shared/opencode-headless-command.test.ts | 30 +++++++ src/shared/opencode-headless-command.ts | 82 ++++++++++++++++++-- src/shared/opencode-startup-prompt.test.ts | 52 +++++++++++++ src/shared/opencode-startup-prompt.ts | 2 +- 6 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index 3a547d35c0e8..53c0e43ff882 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -91,7 +91,7 @@ export async function prepareOpenCodePtyLaunch(options: { const last = parsed.ok ? parsed.tokens.length - 1 : -1 if ( parsed.ok && - !isOpenCodeRunCommand(parsed.tokens) && + !isOpenCodeRunCommand(parsed.tokens, shell) && parsed.tokens.filter((token) => token === '--prompt').length === 1 && parsed.tokens[last - 1] === '--prompt' && parsed.tokens[last] === body && diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts index 28dd70cf0bda..50e1be306faf 100644 --- a/src/shared/flag-prompt-startup.ts +++ b/src/shared/flag-prompt-startup.ts @@ -4,7 +4,7 @@ import type { SessionOptionValue } from './native-chat-session-options' import type { TuiAgent } from './tui-agent' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' -import { isOpenCodeRunCommand } from './opencode-headless-command' +import { findOpenCodeRunCommand } from './opencode-headless-command' import { openCodeStartupPromptEnv } from './opencode-startup-prompt' export function appliedSessionOptionProps(values: Record) { @@ -22,13 +22,13 @@ export function buildFlagPromptStartupPlan(args: { agentEnv: Record | null | undefined }): AgentStartupPlan { const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) - const isOpenCodeRun = - (args.agent === 'opencode' || args.agent === 'opencode2') && - parsed.ok && - isOpenCodeRunCommand(parsed.tokens) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null // OpenCode run takes a positional message; --prompt belongs to its TUI. - const promptSuffix = isOpenCodeRun - ? parsed.tokens.includes('--') + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null ? ` ${args.quotedPrompt}` : ` -- ${args.quotedPrompt}` : ` --prompt ${args.quotedPrompt}` diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb9..cc240be7f9c2 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,32 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) throw new Error(parsed.error) + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fce..858fb9651256 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,82 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index += 1 + if (tokens[index] === '--') { + index += 1 + } + index = tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index d438028ba968..e82b46f8e771 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -105,3 +105,55 @@ describe('native OpenCode startup submission intent', () => { ).toBeUndefined() }) }) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = + '& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone' + + separator + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(command + (separator ? ' ' : ' -- ') + "'--task''s é'") + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts index 1dbe9c2719d8..e3b1c462ffa4 100644 --- a/src/shared/opencode-startup-prompt.ts +++ b/src/shared/opencode-startup-prompt.ts @@ -21,7 +21,7 @@ export function openCodeStartupPromptEnv( if ( (agent !== 'opencode' && agent !== 'opencode2') || !parsed.ok || - isOpenCodeRunCommand(parsed.tokens) + isOpenCodeRunCommand(parsed.tokens, shell) ) { return env ? { env: { ...env } } : {} } From a031e1ad66bf8d140e1db7c6b77eb51d5c1e026d Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 21:57:34 -0700 Subject: [PATCH 66/93] Prepare complete private OpenCode launch validation source Integrate the complete reviewed readiness, capability, native prompt, overlay and positional-run source onto frozen main. Preserve canonical atomic ACL retry, status generator/disposal, restrictive-umask fixtures and unowned source. Keep supported wrapped run commands positional. Private-validation-source: a44345ce496482c2d9d5bcaf7bb890faa3d4d52e Original-full-source: 23fc08b4e939169f0f6bc80d6e2a92e88699258b Original-core-base: 8186ded0bd5549c7bcca6f69d83e18565e1c8648 Frozen-main: 08ee7ba9efa2f3842b7a057a1eb101c824ea0087 Owned-source-paths: 111 Publication-policy: private validation only; preserve the six separate PR boundaries and held model/provider drafts Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Adnan Khan Credits: juli-gonzalez readiness contribution; Ahmed Nagy atomic plugin writer; coelho-doti positional run contribution --- .../server-startup-prompt-control.test.ts | 112 +++++++ .../agent-hooks/server/server-lifecycle.ts | 69 +++-- .../agent-hooks/server/server-listeners.ts | 8 + .../agent-hooks/server/server-runtime-env.ts | 2 +- src/main/agent-hooks/server/server-state.ts | 3 + .../server/server-status-hook-lifecycle.ts | 52 ++++ .../serialize-grid-transcript-replay.test.ts | 5 + src/main/ipc/pty-ipc-mock-registry.ts | 3 + src/main/ipc/pty/host-env/assembly.ts | 71 +---- src/main/ipc/pty/host-env/opencode-config.ts | 84 ++++++ .../opencode-hook-installation.test.ts | 22 ++ src/main/ipc/pty/ipc/spawn-commit.ts | 6 + src/main/ipc/pty/ipc/spawn-options.ts | 15 + src/main/ipc/pty/runtime/spawn-commit.ts | 6 + src/main/ipc/pty/runtime/spawn-options.ts | 16 + .../opencode/hook-plugin-dispose-host.test.ts | 2 + .../hook-plugin-module-contract.test.ts | 9 + .../hook-plugin-opencode2-setup.test.ts | 2 + .../hook-service-overlay-confinement.test.ts | 98 ++++++ src/main/opencode/hook-service.test.ts | 3 + src/main/opencode/hook-service.ts | 66 +++-- .../opencode/opencode-cli-version.test.ts | 131 +++++++++ src/main/opencode/opencode-cli-version.ts | 87 ++++++ .../opencode-launch-capabilities.test.ts | 161 ++++++++++ .../opencode/opencode-launch-capabilities.ts | 96 ++++++ .../opencode-overlay-manifest.test.ts | 32 ++ .../opencode/opencode-overlay-manifest.ts | 29 ++ src/main/opencode/opencode-pty-launch.test.ts | 278 ++++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 128 ++++++++ .../opencode-startup-prompt-claims.test.ts | 74 +++++ .../opencode-startup-prompt-claims.ts | 85 ++++++ .../opencode-startup-prompt-installer.test.ts | 97 ++++++ .../opencode-startup-prompt-installer.ts | 64 ++++ .../opencode-startup-prompt-owner.test.ts | 117 ++++++++ .../opencode/opencode-startup-prompt-owner.ts | 111 +++++++ .../opencode-startup-prompt-source.test.ts | 256 ++++++++++++++++ .../opencode-startup-prompt-source.ts | 117 ++++++++ .../opencode/status-plugin-factory-source.ts | 7 +- src/main/orcad/orcad-entry.ts | 16 +- src/main/orcad/orcad-push-startup.test.ts | 29 +- src/main/providers/pty-spawn-result.ts | 3 + src/main/pty/overlay-mirror.test.ts | 29 +- src/main/pty/overlay-mirror.ts | 35 ++- ...e-1-18-32-timed-boot-hidden-pane.meta.json | 9 + ...1-18-32-timed-boot-hidden-pane.timing.json | 1 + ...pencode-1-18-32-timed-boot-hidden-pane.txt | 1 + ...opencode-1-18-32-timed-boot-slow.meta.json | 9 + ...encode-1-18-32-timed-boot-slow.timing.json | 1 + .../opencode-1-18-32-timed-boot-slow.txt | 1 + ...ncode-1-18-32-timed-first-launch.meta.json | 9 + ...ode-1-18-32-timed-first-launch.timing.json | 1 + .../opencode-1-18-32-timed-first-launch.txt | 1 + ...de-2-0-18-timed-boot-hidden-pane.meta.json | 9 + ...-2-0-18-timed-boot-hidden-pane.timing.json | 1 + ...opencode-2-0-18-timed-boot-hidden-pane.txt | 1 + ...-1-18-32-timed-boot-hidden-pane@agent.json | 13 + ...-18-32-timed-boot-hidden-pane@unknown.json | 13 + ...pencode-1-18-32-timed-boot-slow@agent.json | 13 + ...ncode-1-18-32-timed-boot-slow@unknown.json | 13 + ...code-1-18-32-timed-first-launch@agent.json | 13 + ...de-1-18-32-timed-first-launch@unknown.json | 13 + ...e-2-0-18-timed-boot-hidden-pane@agent.json | 13 + ...2-0-18-timed-boot-hidden-pane@unknown.json | 13 + .../agent-transcript-replay-test-harness.ts | 35 +++ ...draft-paste-ready-scanner-chunking.test.ts | 191 ++++++++++++ .../launched-agent-composer-readiness.test.ts | 170 +++++++++++ .../launched-agent-composer-readiness.ts | 37 +++ ...pencode-worker-composer-transcript.test.ts | 101 +++++++ .../orca-runtime-activate-managed-worktree.ts | 4 +- src/main/runtime/orca-runtime-runtime-id.ts | 19 ++ .../readiness-census-transcript-catalog.ts | 2 + src/main/runtime/readiness-census.test.ts | 12 + .../federation-attachment-authority.ts | 35 +++ ...ederation-launched-agent-readiness.test.ts | 129 ++++++++ .../orchestration/federation/federation.ts | 25 +- .../worker/local-worker-start.ts | 17 +- .../worker/zcode-worker-readiness.test.ts | 44 +-- src/main/runtime/terminal-run-facts.test.ts | 15 + src/main/runtime/terminal-run-facts.ts | 41 ++- src/main/startup/main-process-pty-startup.ts | 4 +- .../startup/main-process-ready-foundation.ts | 2 + src/relay/opencode-overlay-mirror.ts | 40 +++ src/relay/opencode-plugin-selection.ts | 39 +++ .../opencode-startup-prompt-install.test.ts | 86 ++++++ src/relay/plugin-overlay.ts | 89 +++--- src/relay/pty-handler-opencode-revive.test.ts | 142 +++++++++ src/relay/pty-handler.ts | 57 +++- src/relay/relay-agent-hook-runtime.ts | 7 + src/relay/wsl-install-plugins-handler.ts | 24 ++ src/shared/draft-paste-ready-scanner.test.ts | 37 ++- src/shared/draft-paste-ready-scanner.ts | 35 ++- src/shared/flag-prompt-startup.ts | 50 ++++ src/shared/opencode-cli-version.test.ts | 34 +++ src/shared/opencode-cli-version.ts | 26 ++ src/shared/opencode-headless-command.test.ts | 30 ++ src/shared/opencode-headless-command.ts | 82 +++++- src/shared/opencode-startup-prompt-install.ts | 17 ++ src/shared/opencode-startup-prompt.test.ts | 159 ++++++++++ src/shared/opencode-startup-prompt.ts | 39 +++ src/shared/opencode-tui-plugin-install.ts | 16 +- src/shared/tui-agent-config-types.ts | 2 + src/shared/tui-agent-config.ts | 13 +- src/shared/tui-agent-startup.ts | 20 +- 103 files changed, 4449 insertions(+), 262 deletions(-) create mode 100644 src/main/agent-hooks/server-startup-prompt-control.test.ts create mode 100644 src/main/agent-hooks/server/server-status-hook-lifecycle.ts create mode 100644 src/main/ipc/pty/host-env/opencode-config.ts create mode 100644 src/main/opencode/hook-service-overlay-confinement.test.ts create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.test.ts create mode 100644 src/main/opencode/opencode-overlay-manifest.ts create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-claims.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-installer.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-owner.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.test.ts create mode 100644 src/main/opencode/opencode-startup-prompt-source.ts create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json create mode 100644 src/main/runtime/draft-paste-ready-scanner-chunking.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts create mode 100644 src/relay/opencode-overlay-mirror.ts create mode 100644 src/relay/opencode-plugin-selection.ts create mode 100644 src/relay/opencode-startup-prompt-install.test.ts create mode 100644 src/relay/pty-handler-opencode-revive.test.ts create mode 100644 src/shared/flag-prompt-startup.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts create mode 100644 src/shared/opencode-startup-prompt-install.ts create mode 100644 src/shared/opencode-startup-prompt.test.ts create mode 100644 src/shared/opencode-startup-prompt.ts diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts new file mode 100644 index 000000000000..1704b518c7ad --- /dev/null +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' + +describe('startup prompt control with status hooks disabled', () => { + it('enables and disables status without restarting the control listener', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-toggle-')) + class IsolatedHookServer extends AgentHookServer { + constructor() { + super() + this._setOpenCodeBinderDepsForTests({ + dbPath: () => join(dir, 'no-user-db'), + listSessions: () => [], + listPanes: () => [], + sweep: async () => [] + }) + } + } + const server = new IsolatedHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'x-orca-agent-hook-token': coords.token }, + body: '{}' + }) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start({ statusHooksEnabled: true }) + expect(server.buildPtyEnv()).toHaveProperty('ORCA_AGENT_HOOK_PORT', coords.port) + expect((await post('/hook/opencode')).status).toBe(204) + server.setStatusHooksEnabled(false) + expect(server.buildPtyEnv()).toEqual({}) + expect((await post('/hook/opencode')).status).toBe(404) + await server.start() + expect((await post('/hook/opencode')).status).toBe(404) + server.setStartupPromptClaimListener( + () => 'pending', + () => {} + ) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH)).json()).toEqual({ + allowed: false, + pending: true + }) + server.setStatusHooksEnabled(true) + expect((await post('/hook/opencode')).status).toBe(204) + expect(server.endpointFilePath).toBe(endpoint) + expect(parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8'))).toEqual(coords) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('authenticates claims, denies malformed or missing handlers, and refuses status posts', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-prompt-control-')) + const server = new AgentHookServer() + try { + await server.start({ userDataPath: dir, statusHooksEnabled: false }) + expect(server.buildPtyEnv()).toEqual({}) + const statusPath = server.lastStatusPath + if (!statusPath) { + throw new Error('missing status persistence path') + } + writeFileSync(statusPath, 'existing status must survive control-only shutdown') + const endpoint = server.endpointFilePath + if (!endpoint) { + throw new Error('missing control endpoint') + } + const coords = parseAgentHookEndpointFile(readFileSync(endpoint, 'utf8')) + const post = (path: string, body: string, token = coords.token) => + fetch(`http://127.0.0.1:${coords.port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-orca-agent-hook-token': token }, + body + }) + expect((await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}', 'wrong')).status).toBe(403) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: false + }) + const clear = vi.fn() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, clear) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{}')).json()).toEqual({ + allowed: true + }) + expect(await (await post(OPENCODE_STARTUP_PROMPT_CLAIM_PATH, '{')).json()).toEqual({ + allowed: false + }) + expect(claim).toHaveBeenCalledTimes(1) + expect((await post('/hook/opencode', '{}')).status).toBe(404) + expect((await post('/statusline/claude', '{}')).status).toBe(404) + server.stop() + expect(clear).toHaveBeenCalledTimes(1) + expect(readFileSync(statusPath, 'utf8')).toBe( + 'existing status must survive control-only shutdown' + ) + } finally { + server.stop() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 80c81f091427..417df6a3ee94 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -11,21 +11,26 @@ import { readRequestBody } from '../../../shared/agent-hook-listener/request-bod import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' -import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' import { trackEmptyPaneKeyHook } from './server-transport-rules' -import { AgentHookServerRuntimeEnv } from './server-runtime-env' +import { AgentHookServerStatusHookLifecycle } from './server-status-hook-lifecycle' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../../shared/opencode-startup-prompt' -export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { +export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHookLifecycle { /** Start the loopback listener after hydration and spool replay have settled. */ async start(options?: { env?: string userDataPath?: string endpointNamespace?: string + statusHooksEnabled?: boolean }): Promise { if (this.server) { + if (options?.statusHooksEnabled !== undefined) { + this.setStatusHooksEnabled(options.statusHooksEnabled) + } return } + this.statusHooksEnabled = options?.statusHooksEnabled !== false if (options?.env) { this.env = options.env @@ -37,30 +42,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null - if (!this.ownerStateInitialized) { - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - const replayedPaneKeys = new Set() - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => { - this.ingestSpoolRecord(record) - replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) - } - }) - // Why: the owner may have died while Orca was down; check each replayed pane once. - for (const paneKey of replayedPaneKeys) { - void this.checkAgentPresence(paneKey) - } - } - this.ownerStateInitialized = true + if (this.statusHooksEnabled) { + this.initializeStatusHookOwner() } const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { if (req.method !== 'POST') { @@ -84,6 +67,22 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname try { const body = await readRequestBody(req) + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + const claim = this.onStartupPromptClaim?.(body) + res.end( + JSON.stringify({ + allowed: claim === true, + ...(claim === 'pending' ? { pending: true } : {}) + }) + ) + return + } + if (!this.statusHooksEnabled) { + res.writeHead(404) + res.end() + return + } if (pathname === CLAUDE_STATUSLINE_PATHNAME) { const statusLineEvent = parseClaudeStatusLineBody(body) if (statusLineEvent) { @@ -152,6 +151,11 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv res.writeHead(204) res.end() } catch (error) { + if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"allowed":false}') + return + } // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut // by something on the loopback path, not by a bad payload. Fail open as before, but count it — // this is the one failure mode that silently stops status for every runtime at once. @@ -192,7 +196,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv this.rollbackTransportStart() throw error } - this.startOpenCodeBinderLoop() + if (this.statusHooksEnabled) { + this.startOpenCodeBinderLoop() + } } private rollbackTransportStart(): void { @@ -205,13 +211,18 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv stop(): void { // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() + if (this.statusHooksEnabled) { + this.flushStatusPersistSync() + } this.stopOpenCodeBinderLoop() this.stopTmuxStatus() this.rollbackTransportStart() this.env = 'production' this.onAgentStatus = null this.onClaudeStatusLine = null + this.clearStartupPromptClaims?.() + this.clearStartupPromptClaims = null + this.onStartupPromptClaim = null this.onPaneStatusCleared = null this.onTransportInterference = null this.transportInterference.reset() diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 1de09c3ebdf7..14b782627349 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -26,6 +26,14 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row' const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER export abstract class AgentHookServerListeners extends AgentHookServerState { + setStartupPromptClaimListener( + listener: (body: unknown) => boolean | 'pending', + clear: () => void + ): void { + this.onStartupPromptClaim = listener + this.clearStartupPromptClaims = clear + } + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { this.onAgentStatus?.(enriched) for (const listener of this.enrichedStatusListeners) { diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts index e5b9e4c13630..dfb35cae6be5 100644 --- a/src/main/agent-hooks/server/server-runtime-env.ts +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -11,7 +11,7 @@ import { AgentHookServerIngestRemote } from './server-ingest-remote' export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { + if (!this.statusHooksEnabled || this.port <= 0 || !this.token) { return {} } const env: Record = { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index f429c0e63501..172eaeaf1efd 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -89,6 +89,9 @@ export abstract class AgentHookServerState { protected env = 'production' protected onAgentStatus: ServerAgentStatusListener = null protected onClaudeStatusLine: ServerStatusLineListener = null + protected onStartupPromptClaim: ((body: unknown) => boolean | 'pending') | null = null + protected clearStartupPromptClaims: (() => void) | null = null + protected statusHooksEnabled = true protected onPaneStatusCleared: PaneStatusClearListener | null = null protected paneStatusClearListeners = new Set() protected statusDropListeners = new Set() diff --git a/src/main/agent-hooks/server/server-status-hook-lifecycle.ts b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts new file mode 100644 index 000000000000..15a43182ab28 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-hook-lifecycle.ts @@ -0,0 +1,52 @@ +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerStatusHookLifecycle extends AgentHookServerRuntimeEnv { + setStatusHooksEnabled(enabled: boolean): void { + if (enabled === this.statusHooksEnabled) { + return + } + if (!enabled) { + this.flushStatusPersistSync() + this.stopOpenCodeBinderLoop() + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllTranscriptPolls() + } + this.statusHooksEnabled = enabled + if (enabled && this.server) { + this.initializeStatusHookOwner() + this.startOpenCodeBinderLoop() + } + } + + protected initializeStatusHookOwner(): void { + if (!this.ownerStateInitialized) { + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + const replayedPaneKeys = new Set() + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => { + this.ingestSpoolRecord(record) + replayedPaneKeys.add(this.resolvePaneKeyAlias(record.paneKey)) + } + }) + // Why: the owner may have died while Orca was down; check each replayed pane once. + for (const paneKey of replayedPaneKeys) { + void this.checkAgentPresence(paneKey) + } + } + this.ownerStateInitialized = true + } + } +} diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index b055aef804d1..d39078692c6a 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // Captured boots expose the existing background/cursor restore loss; serializer code is unchanged. + 'opencode-1-18-32-timed-boot-hidden-pane': 16, + 'opencode-1-18-32-timed-boot-slow': 16, + 'opencode-1-18-32-timed-first-launch': 16, + 'opencode-2-0-18-timed-boot-hidden-pane': 14, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index f49978843d56..4eba8d0b1641 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -106,6 +106,9 @@ export const childProcessModuleMock = (original: Record) => ({ }) export const openCodeHookServiceModuleMock = () => ({ + OpenCodeHookService: class { + buildPtyEnv = vi.fn(() => ({})) + }, openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, refreshLegacySharedPlugin: vi.fn<() => void>(), diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index 324dbe60dc7a..88b5df165d17 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,15 +1,10 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' -import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' -import { - OPENCODE_CONFIG_DIR_ENV_KEYS, - isOpenCodeLegacySharedConfigDir -} from '../../../opencode/legacy-shared-config-dir' +import { ensureOpenCodeStartupPromptForLaunch } from '../../../opencode/opencode-startup-prompt-installer' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -28,12 +23,12 @@ import { exposePiManagedExtensionEnv, isMimoLaunchCommand, resolveMimocodeSourceHome, - resolveOpenCodeSourceConfigDir, resolvePiAgentSourceDir, resolveScopedPiAgentSourceDir, restoreOrStripOverlayEnv } from './pi-agent' import { AGENT_HOOK_RUNTIME_ENV_KEYS } from './spawn-env-keys' +import { applyOpenCodeStatusPluginEnv, captureOpenCodeSourceConfig } from './opencode-config' /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. @@ -49,31 +44,8 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. - const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => - isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) - const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} - for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { - if (isLegacyOpenCodeHooksDir(baseEnv[key])) { - delete baseEnv[key] - } - if (!isLegacyOpenCodeHooksDir(process.env[key])) { - inheritedOpenCodeEnv[key] = process.env[key] - } - } - // A daemon or sibling shell can retain a retired path that main no longer sees. - openCodeHookService.refreshLegacySharedPlugin() - openCode2HookService.refreshLegacySharedPlugin() - const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) - const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) - ? undefined - : resolvedOpenCodeConfigDir + const openCodeConfig = captureOpenCodeSourceConfig(baseEnv, opts.userDataPath) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - const openCodeAgent = selectOpenCodeHookAgent( - opts.launchAgent, - launchCommandHint, - (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) - ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -108,37 +80,13 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv( + const openCodeAgent = applyOpenCodeStatusPluginEnv( + id, baseEnv, - { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }, - inheritedOpenCodeEnv + openCodeConfig, + opts, + launchCommandHint ) - delete baseEnv.ORCA_OPENCODE_AGENT - if (openCodeAgent) { - // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. - const openCodeStatusService = - openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService - baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - // WSL owns its config writes; only the guest overlay may enter a WSL pane. - if (!opts.isWsl) { - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - } - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: nested Orca terminals inherit the overlay as OPENCODE_CONFIG_DIR; keep the real source so overlays don't mirror overlays. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir - } else { - delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR - } - } - } if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) @@ -341,5 +289,8 @@ export function buildPtyHostEnv( // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) + if (!opts.isWsl) { + ensureOpenCodeStartupPromptForLaunch(baseEnv) + } return baseEnv } diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts new file mode 100644 index 000000000000..88dafabc88ea --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -0,0 +1,84 @@ +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' +import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' +import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' +import type { BuildPtyHostEnvOptions } from './types' + +type OpenCodeSourceConfig = { + inheritedEnv: NodeJS.ProcessEnv + directory: string | undefined +} + +export function captureOpenCodeSourceConfig( + env: Record, + userDataPath: string +): OpenCodeSourceConfig { + const isLegacyDirectory = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, userDataPath) + const inheritedEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyDirectory(env[key])) { + delete env[key] + } + if (!isLegacyDirectory(process.env[key])) { + inheritedEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const directory = resolveOpenCodeSourceConfigDir(env, inheritedEnv) + return { inheritedEnv, directory: isLegacyDirectory(directory) ? undefined : directory } +} + +export function applyOpenCodeStatusPluginEnv( + id: string, + env: Record, + config: OpenCodeSourceConfig, + options: Pick< + BuildPtyHostEnvOptions, + 'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' + >, + command: string | undefined +): 'opencode' | 'opencode2' | null { + const agent = selectOpenCodeHookAgent( + options.launchAgent, + command, + (candidate) => + options.agentStatusHooksEnabled && isTuiAgentEnabled(candidate, options.disabledTuiAgents) + ) + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + config.inheritedEnv + ) + delete env.ORCA_OPENCODE_AGENT + if (!agent) { + return null + } + const service = agent === 'opencode2' ? openCode2HookService : openCodeHookService + env.ORCA_OPENCODE_AGENT = agent + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!options.isWsl) { + Object.assign(env, service.buildPtyEnv(id, config.directory)) + } + if (env.OPENCODE_CONFIG_DIR) { + // Shell startup can re-export the default; preserve this pane's overlay and original source. + env.ORCA_OPENCODE_CONFIG_DIR = env.OPENCODE_CONFIG_DIR + if (config.directory) { + env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = config.directory + } else { + delete env.ORCA_OPENCODE_SOURCE_CONFIG_DIR + } + } + return agent +} diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index 8a9114eb9427..dc62b1fad91a 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -77,6 +77,28 @@ afterEach(() => { }) describe('OpenCode installation uses the current enabled agents', () => { + it('refuses spawn if a prepared startup intent loses its owned installer', () => { + mkdirSync(fixture.userData, { recursive: true }) + writeFileSync( + join(fixture.userData, 'opencode-startup-prompt-overlays'), + 'blocked fixture root' + ) + expect(() => + buildPtyHostEnv( + 'owned-launch', + { + OPENCODE_CONFIG_DIR: custom, + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'fixture-nonce', + ORCA_OPENCODE_STARTUP_PROMPT_BODY: 'original caller brief' + }, + { ...options, agentStatusHooksEnabled: false } + ) + ).toThrow('launch was canceled') + expect(readFileSync(join(custom, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(readFileSync(join(custom, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + }) + const combinations = [ { disabled: [], fallback: 'opencode' }, { disabled: ['opencode'], fallback: 'opencode2' }, diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index 77491c9fb11b..24df59c1201c 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -22,8 +22,13 @@ import { admitPtyReattachOwnership, registerPersistedPtySpawn } from '../pane/sp import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { markNativeWindowsConptyPty } from '../../../runtime/terminal-model-query-authority' +import { commitPtyWithOpenCodePromptIntent } from '../../../opencode/opencode-startup-prompt-owner' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { + return commitPtyWithOpenCodePromptIntent(ctx, () => commitReservedPtyIpcSpawn(ctx)) +} + +async function commitReservedPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) if (ctx.nativeWindowsConptySpawn) { @@ -100,6 +105,7 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise commitReservedRuntimePtySpawn(ctx)) +} + +async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { const args = ctx.args admitPtyReattachOwnership(ctx.deps.runtime, ctx.result, args.connectionId) const providerReattachLaunchIdentity = admitProviderReattachLaunchIdentity(ctx.result) @@ -205,6 +210,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (ctx.result.incarnationId) { ptyIncarnationById.set(ctx.result.id, ctx.result.incarnationId) } + claimSshPaneLease({ store: ctx.deps.store, connectionId: args.connectionId, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d413c0556f09..2c52daa87a41 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -35,6 +35,7 @@ import { import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -111,6 +112,21 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) + const openCodeLaunch = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.env = openCodeLaunch.env + ctx.launchCommand = openCodeLaunch.command + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts index 179714d8c831..52e152753a4f 100644 --- a/src/main/opencode/hook-plugin-dispose-host.test.ts +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -34,6 +34,7 @@ type PluginModule = { const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', } process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a0..f6d07361abe2 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 1ada5ea1a53d..0fa86f3d70d8 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag savedEnv[key] = process.env[key] } process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-service-overlay-confinement.test.ts b/src/main/opencode/hook-service-overlay-confinement.test.ts new file mode 100644 index 000000000000..46a5bee58843 --- /dev/null +++ b/src/main/opencode/hook-service-overlay-confinement.test.ts @@ -0,0 +1,98 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { safeRemoveTree } from '../pty/overlay-mirror' +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_OVERLAY_MANIFEST_FILE } from './opencode-overlay-manifest' + +let root: string +let source: string +let service: OpenCodeHookService + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-overlay-confinement-')) + source = join(root, 'source') + mkdirSync(join(source, 'plugins'), { recursive: true }) + writeFileSync(join(source, 'plugins', 'user.js'), 'export default {}') + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => process.cwd(), + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + service = new OpenCodeHookService({ + pluginFileName: 'orca-test.js', + legacyHooksDir: 'legacy-test', + overlayDir: 'overlay-test', + pluginSource: () => 'export default {}' + }) +}) + +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +it.each(['overlay-root', 'source-overlay', 'plugins'] as const)( + 'preserves outside files when the owned %s is replaced by a directory link', + (boundary) => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + const outside = join(root, 'outside') + const externalOverlay = boundary === 'overlay-root' ? join(outside, basename(overlay)) : outside + mkdirSync(join(externalOverlay, 'plugins'), { recursive: true }) + const sentinel = join(externalOverlay, 'plugins', 'keep.js') + writeFileSync(sentinel, 'export const keep = true') + writeFileSync( + join(externalOverlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + const replaced = + boundary === 'overlay-root' + ? dirname(overlay) + : boundary === 'source-overlay' + ? overlay + : join(overlay, 'plugins') + const target = boundary === 'plugins' ? join(outside, 'plugins') : outside + if (boundary === 'plugins') { + writeFileSync( + join(overlay, OPENCODE_OVERLAY_MANIFEST_FILE), + JSON.stringify({ topLevelEntries: [], pluginEntries: ['keep.js'] }) + ) + } + safeRemoveTree(replaced) + symlinkSync(target, replaced, process.platform === 'win32' ? 'junction' : 'dir') + const before = readdirSync(outside, { recursive: true }).toSorted() + + const result = service.buildPtyEnv('pane-2', source) + expect(readFileSync(sentinel, 'utf8')).toBe('export const keep = true') + expect(readdirSync(outside, { recursive: true }).toSorted()).toEqual(before) + expect(result).toEqual({ OPENCODE_CONFIG_DIR: source }) + } +) + +it('still removes a stale mirrored entry from a real owned overlay', () => { + const overlay = service.buildPtyEnv('pane-1', source).OPENCODE_CONFIG_DIR + if (!overlay) { + throw new Error('Expected an isolated overlay') + } + rmSync(join(source, 'plugins', 'user.js')) + expect(service.buildPtyEnv('pane-2', source)).toEqual({ OPENCODE_CONFIG_DIR: overlay }) + expect(readdirSync(join(overlay, 'plugins'))).toEqual(['orca-test.js']) +}) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index ff124eb0b951..2c904c948a24 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,6 +32,7 @@ import { import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' beforeEach(() => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') setAppEnvironment({ getPath: getPathMock, getAppPath: () => process.cwd(), @@ -43,6 +44,8 @@ beforeEach(() => { }) }) +afterEach(() => vi.unstubAllEnvs()) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode id safety guard', () => { diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 41f65243ab91..2a1b914b4d1f 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -2,6 +2,7 @@ import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { existsSync, + lstatSync, mkdirSync, readFileSync, readdirSync, @@ -10,12 +11,17 @@ import { writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mirrorEntry, safeRemoveTree } from '../pty/overlay-mirror' +import { isSafeDescendCandidate, mirrorEntry, safeRemoveOverlay } from '../pty/overlay-mirror' import { getOpenCode2PluginSource, getOpenCodeFamilyPluginSource, getOpenCodePluginSource } from './status-plugin-module-source' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE, + type OpenCodeOverlayManifest +} from './opencode-overlay-manifest' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' import { getOpenCodeLegacySharedConfigDir, @@ -28,7 +34,8 @@ import { } from '../../shared/opencode-installed-plugin' import { openCodeTuiPluginDirName, - writeOpenCodeTuiPlugin + writeOpenCodeTuiPlugin, + writeOpenCodeTuiPluginDirectory } from '../../shared/opencode-tui-plugin-install' import { writeLegacyOpenCodePluginWithAclRetry } from './legacy-plugin-acl-retry' @@ -41,12 +48,6 @@ import { const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' - -type OpenCodeOverlayManifest = { - topLevelEntries: string[] - pluginEntries: string[] -} type OpenCodeHookVariant = { pluginFileName: string @@ -55,6 +56,7 @@ type OpenCodeHookVariant = { pluginSource: () => string /** Also install the module as an OpenCode 2 TUI plugin (never for forks without one). */ installsTuiPlugin?: boolean + tuiOnlyDirectory?: string } // Why: session IDs may contain path separators and are hashed downstream; cap pathological input. @@ -74,6 +76,7 @@ export class OpenCodeHookService { private readonly legacyHooksDir: string private readonly overlayDir: string private readonly installsTuiPlugin: boolean + private readonly tuiOnlyDirectory: string | undefined constructor(variant?: OpenCodeHookVariant | (() => string)) { const config: OpenCodeHookVariant = @@ -93,6 +96,7 @@ export class OpenCodeHookService { }) this.pluginSource = config.pluginSource this.installsTuiPlugin = config.installsTuiPlugin === true + this.tuiOnlyDirectory = config.tuiOnlyDirectory this.pluginFileName = config.pluginFileName this.legacyHooksDir = config.legacyHooksDir this.overlayDir = config.overlayDir @@ -118,13 +122,21 @@ export class OpenCodeHookService { return {} } } - if (!existsSync(existingConfigDir)) { + if (!existsSync(existingConfigDir) && !this.tuiOnlyDirectory) { return { OPENCODE_CONFIG_DIR: existingConfigDir } } const overlayDir = this.getSourceOverlayDir(existingConfigDir) try { - mkdirSync(overlayDir, { recursive: true }) - this.mirrorUserConfig(existingConfigDir, overlayDir) + // Owned directories must stay real; a replaced parent redirects both cleanup and writes. + for (const directory of [this.getOverlayRoot(), overlayDir, join(overlayDir, 'plugins')]) { + mkdirSync(directory, { recursive: true }) + if (!isSafeDescendCandidate(lstatSync(directory))) { + return { OPENCODE_CONFIG_DIR: existingConfigDir } + } + } + if (existsSync(existingConfigDir)) { + this.mirrorUserConfig(existingConfigDir, overlayDir) + } this.writePluginIntoOverlay(overlayDir) return { OPENCODE_CONFIG_DIR: overlayDir } } catch { @@ -135,6 +147,9 @@ export class OpenCodeHookService { // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. refreshLegacySharedPlugin(): void { + if (this.tuiOnlyDirectory) { + return + } const pluginsDir = join(this.getSharedConfigDir(), 'plugins') const pluginPath = join(pluginsDir, this.pluginFileName) try { @@ -198,20 +213,6 @@ export class OpenCodeHookService { ) } - private readOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { - try { - const parsed = JSON.parse( - readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') - ) as Partial - return { - topLevelEntries: Array.isArray(parsed.topLevelEntries) ? parsed.topLevelEntries : [], - pluginEntries: Array.isArray(parsed.pluginEntries) ? parsed.pluginEntries : [] - } - } catch { - return { topLevelEntries: [], pluginEntries: [] } - } - } - private writeOverlayManifest(overlayDir: string, manifest: OpenCodeOverlayManifest): void { writeFileSync( join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), @@ -221,7 +222,7 @@ export class OpenCodeHookService { private clearManifestEntries(overlayDir: string, manifest: OpenCodeOverlayManifest): void { for (const entryName of manifest.topLevelEntries) { - safeRemoveTree(join(overlayDir, entryName)) + safeRemoveOverlay(join(overlayDir, entryName), overlayDir) } const overlayPluginsDir = join(overlayDir, 'plugins') @@ -229,13 +230,13 @@ export class OpenCodeHookService { if (entryName === this.pluginFileName) { continue } - safeRemoveTree(join(overlayPluginsDir, entryName)) + safeRemoveOverlay(join(overlayPluginsDir, entryName), overlayPluginsDir) } } // Why: mirror user config entries as symlinks so edits propagate live; only plugins/ becomes a real overlay dir so Orca can drop a sibling plugin file. private mirrorUserConfig(sourceDir: string, overlayDir: string): void { - const previousManifest = this.readOverlayManifest(overlayDir) + const previousManifest = readOpenCodeOverlayManifest(overlayDir) // Why: overlays persist across terminals; remove only Orca-mirrored paths so stale user config clears but OpenCode runtime dirs (node_modules) survive. this.clearManifestEntries(overlayDir, previousManifest) @@ -266,6 +267,7 @@ export class OpenCodeHookService { // Why: skip a user plugin sharing Orca's filename; mirroring it would let writePluginIntoOverlay clobber the user's file. if ( pluginEntry.name === this.pluginFileName || + pluginEntry.name === this.tuiOnlyDirectory || (this.installsTuiPlugin && pluginEntry.name === openCodeTuiPluginDirName(this.pluginFileName)) ) { @@ -294,6 +296,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source, 'overlay') + return + } this.writeTuiPlugin(pluginsDir, source, 'overlay') if (!isOverlayOpenCodePluginCurrent(pluginPath, source)) { writeOverlayOpenCodePluginAtomically(pluginPath, source) @@ -305,6 +311,10 @@ export class OpenCodeHookService { mkdirSync(pluginsDir, { recursive: true }) const pluginPath = join(pluginsDir, this.pluginFileName) const source = this.pluginSource() + if (this.tuiOnlyDirectory) { + writeOpenCodeTuiPluginDirectory(pluginsDir, this.tuiOnlyDirectory, source) + return + } this.writeTuiPlugin(pluginsDir, source) if (!isInstalledOpenCodePluginCurrent(pluginPath, source)) { writeCanonicalOpenCodePluginAtomically(pluginPath, source) diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 000000000000..23f6b9eea3fe --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) + +it('shares probes across pane identities while preserving launch-affecting environment keys', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.0.16', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/pane-cache/opencode', + cwd: '/workspace', + env: { + PATH: '/runtime', + OPENCODE_CONFIG_DIR: '/config', + ORCA_PANE_KEY: 'pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_TERMINAL_HANDLE: 'term-1', + ORCA_WORKTREE_ID: 'workspace-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_PANE: 'pane-1', + ORCA_AGENT_LAUNCH: 'launch-1' + }, + hostIdentity: 'pane-cache-host' + } + await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion({ + ...options, + env: { + ...options.env, + ORCA_PANE_KEY: 'pane-2', + ORCA_TAB_ID: 'tab-2', + ORCA_TERMINAL_HANDLE: 'term-2', + ORCA_WORKTREE_ID: 'workspace-2', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-2', + ORCA_AGENT_PANE: 'pane-2', + ORCA_AGENT_LAUNCH: 'launch-2' + } + }) + ]) + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } }) + await probeOpenCodeCliVersion({ + ...options, + env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' } + }) + await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' }) + expect(runProcess).toHaveBeenCalledTimes(4) +}) + +it('separates native and host-callback backends even with the same host identity', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.32', + stderr: '', + timedOut: false + }) + const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' } + expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1') + const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' })) + expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2') + expect(execute).toHaveBeenCalledOnce() + expect(runProcess).toHaveBeenCalledOnce() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 000000000000..06804bad2eac --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 +const PANE_IDENTITY_ENV_KEYS = new Set([ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV +]) + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.execute ? 'host-callback' : 'native-process', + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env) + .filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = options.execute + ? await options.execute() + : await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts new file mode 100644 index 000000000000..9d15cefe80f2 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getOpenCodeLaunchExecutable, + probeOpenCodeLaunchCapabilities +} from './opencode-launch-capabilities' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' + +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + cwd: '/home/user/repo', + env: guestEnv + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + cwd: '/home/user/repo', + program: 'opencode', + args: ['--version'], + env: guestEnv, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: {} }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 000000000000..17a2a570872f --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,96 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + const guestEnv: Record = {} + const wslEnv = options.env.WSLENV + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + } + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + cwd, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: async () => { + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + cwd, + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.environmentResolved ? result : { ...result, code: null } + } + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} diff --git a/src/main/opencode/opencode-overlay-manifest.test.ts b/src/main/opencode/opencode-overlay-manifest.test.ts new file mode 100644 index 000000000000..7dc0404a53e6 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.test.ts @@ -0,0 +1,32 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + readOpenCodeOverlayManifest, + OPENCODE_OVERLAY_MANIFEST_FILE +} from './opencode-overlay-manifest' + +it('accepts only string manifest entries and tolerates invalid persisted shapes', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-overlay-manifest-')) + try { + const path = join(root, OPENCODE_OVERLAY_MANIFEST_FILE) + for (const text of ['null', '1', '{']) { + writeFileSync(path, text) + expect(readOpenCodeOverlayManifest(root)).toEqual({ topLevelEntries: [], pluginEntries: [] }) + } + writeFileSync( + path, + JSON.stringify({ + topLevelEntries: ['valid', 1, null], + pluginEntries: [{ bad: true }, 'plugin.js'] + }) + ) + expect(readOpenCodeOverlayManifest(root)).toEqual({ + topLevelEntries: ['valid'], + pluginEntries: ['plugin.js'] + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/opencode/opencode-overlay-manifest.ts b/src/main/opencode/opencode-overlay-manifest.ts new file mode 100644 index 000000000000..5abd0328f257 --- /dev/null +++ b/src/main/opencode/opencode-overlay-manifest.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +export const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' +export type OpenCodeOverlayManifest = { topLevelEntries: string[]; pluginEntries: string[] } + +export function readOpenCodeOverlayManifest(overlayDir: string): OpenCodeOverlayManifest { + const empty = { topLevelEntries: [], pluginEntries: [] } + try { + const parsed: unknown = JSON.parse( + readFileSync(join(overlayDir, OPENCODE_OVERLAY_MANIFEST_FILE), 'utf8') + ) + if (!parsed || typeof parsed !== 'object') { + return empty + } + return { + topLevelEntries: + 'topLevelEntries' in parsed && Array.isArray(parsed.topLevelEntries) + ? parsed.topLevelEntries.filter((entry): entry is string => typeof entry === 'string') + : [], + pluginEntries: + 'pluginEntries' in parsed && Array.isArray(parsed.pluginEntries) + ? parsed.pluginEntries.filter((entry): entry is string => typeof entry === 'string') + : [] + } + } catch { + return empty + } +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 000000000000..d265ecc8747a --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,278 @@ +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} + +const hookServer = vi.hoisted(() => { + const server: { endpointFilePath: string | null } = { endpointFilePath: '/private/endpoint.env' } + return server +}) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: hookServer })) +const reserve = vi.hoisted(() => vi.fn(() => true)) +vi.mock('./opencode-startup-prompt-owner', () => ({ reserveOpenCodeStartupPrompt: reserve })) + +async function prepare(options: Parameters[0]) { + return (await prepareOpenCodePtyLaunch(options)).env +} + +const probe = vi.hoisted(() => vi.fn()) +const install = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-startup-prompt-installer', () => ({ + installOpenCodeStartupPromptForLaunch: install +})) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => { + probe.mockReset() + reserve.mockReset().mockReturnValue(true) + install.mockReset() + hookServer.endpointFilePath = '/private/endpoint.env' +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('execution-host OpenCode launch preparation', () => { + it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) + .command + ).toBe("opencode --prompt 'task'") + }) + it.each(['endpoint', 'installer', 'capacity', 'identity'])( + 'keeps the editable brief when automatic preparation lacks %s', + async (failure) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + if (failure === 'endpoint') { + hookServer.endpointFilePath = null + } + if (failure === 'installer') { + install.mockImplementation((env) => { + delete env.ORCA_OPENCODE_STARTUP_PROMPT_NONCE + }) + } + if (failure === 'capacity') { + reserve.mockReturnValue(false) + } + const original = "opencode --standalone --prompt 'task'" + const result = await prepareOpenCodePtyLaunch({ + command: original, + envToDelete: [], + isFreshLaunch: true, + env: { + ...(failure === 'identity' ? {} : { ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch' }), + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + }) + expect(result.command).toBe(original) + expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') + } + ) + it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( + 'gates native intent against the executing %s capability', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const envToDelete: string[] = [] + const fingerprint = createHash('sha256').update('task').digest('hex') + const env = await prepare({ + command: 'opencode --prompt task', + env: { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }, + envToDelete, + isFreshLaunch: true + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( + version === '2.0.16' ? fingerprint : undefined + ) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + } + ) + + it('refuses remote automatic intent without execution-owned driving input', async () => { + const fingerprint = 'b'.repeat(64) + const envToDelete: string[] = [] + const env = await prepare({ + command: 'opencode --prompt task', + connectionId: 'remote', + isFreshLaunch: true, + env: { [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: fingerprint }, + envToDelete + }) + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBeUndefined() + expect(envToDelete).toContain(OPENCODE_STARTUP_PROMPT_SHA256_ENV) + expect(probe).not.toHaveBeenCalled() + }) + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepare({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepare({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepare({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + envToDelete: [], + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepare({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepare({ + command: 'opencode', + agent: 'opencode', + env, + envToDelete: [], + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepare({ command: 'opencode', env, envToDelete: [], ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 000000000000..53c0e43ff882 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,128 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { randomUUID, createHash } from 'node:crypto' +import { agentHookServer } from '../agent-hooks/server' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' +import { isOpenCodeRunCommand } from '../../shared/opencode-headless-command' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../../shared/opencode-startup-prompt' + +const intentKeys = [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +] + +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { reserveOpenCodeStartupPrompt } from './opencode-startup-prompt-owner' +import { installOpenCodeStartupPromptForLaunch } from './opencode-startup-prompt-installer' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + envToDelete: string[] + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise<{ env: Record | undefined; command: string | undefined }> { + let command = options.command + const env = options.env ? { ...options.env } : undefined + const requestedPrompt = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const body = env?.[OPENCODE_STARTUP_PROMPT_BODY_ENV] + const shell = env?.[OPENCODE_STARTUP_PROMPT_SHELL_ENV] + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + for (const key of intentKeys) { + delete env[key] + } + } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } + for (const key of intentKeys) { + if (!options.envToDelete.includes(key)) { + options.envToDelete.push(key) + } + } + if (options.connectionId || !options.isFreshLaunch) { + return { env, command } + } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: probeEnv + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return { env, command } + } + const launchEnv: Record = { + ...env, + ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi + } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if ( + capabilities.promptMode === 'prefill' && + !options.wsl && + launchEnv.ORCA_AGENT_LAUNCH_TOKEN && + requestedPrompt && + body && + command && + (shell === 'posix' || shell === 'powershell' || shell === 'cmd') && + createHash('sha256').update(body).digest('hex') === requestedPrompt + ) { + const parsed = tokenizeStartupCommand(command, shell) + const last = parsed.ok ? parsed.tokens.length - 1 : -1 + if ( + parsed.ok && + !isOpenCodeRunCommand(parsed.tokens, shell) && + parsed.tokens.filter((token) => token === '--prompt').length === 1 && + parsed.tokens[last - 1] === '--prompt' && + parsed.tokens[last] === body && + !parsed.spans[last].divergesFromShell && + !parsed.spans[last - 1].divergesFromShell + ) { + const endpoint = agentHookServer.endpointFilePath + if (endpoint) { + launchEnv[OPENCODE_STARTUP_PROMPT_SHA256_ENV] = requestedPrompt + launchEnv[OPENCODE_STARTUP_PROMPT_BODY_ENV] = body + launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] = randomUUID() + launchEnv[OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV] = endpoint + installOpenCodeStartupPromptForLaunch(launchEnv, false) + const nonce = launchEnv[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce && reserveOpenCodeStartupPrompt(nonce, requestedPrompt)) { + command = command.slice(0, parsed.spans[last - 1].start).trimEnd() + } else { + for (const key of intentKeys) { + delete launchEnv[key] + } + } + for (const key of intentKeys) { + if (launchEnv[key]) { + options.envToDelete.splice(options.envToDelete.indexOf(key), 1) + } + } + } + } + } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return { env: launchEnv, command } +} diff --git a/src/main/opencode/opencode-startup-prompt-claims.test.ts b/src/main/opencode/opencode-startup-prompt-claims.test.ts new file mode 100644 index 000000000000..9d469b804731 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +describe('execution-owned startup prompt claims', () => { + it('consumes each nonce once and checks owner facts at claim time', () => { + const claims = new OpenCodeStartupPromptClaims() + let facts: TerminalRunFacts | null = { freshSpawn: true, firstUserInputAt: null } + claims.register('first', 'hash', () => facts) + facts.firstUserInputAt = 1 + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + facts.firstUserInputAt = null + expect(claims.claim({ nonce: 'first', digest: 'hash' })).toBe(false) + claims.register('second', 'hash', () => facts) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(true) + expect(claims.claim({ nonce: 'second', digest: 'hash' })).toBe(false) + claims.register('missing', 'hash', () => facts) + facts = null + expect(claims.claim({ nonce: 'missing', digest: 'hash' })).toBe(false) + }) + + it('refuses reattachment, mismatched hashes, expired claims and malformed bodies', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('reattach', 'hash', () => ({ freshSpawn: false, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'reattach', digest: 'hash' })).toBe(false) + claims.register('mismatch', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'mismatch', digest: 'other' })).toBe(false) + expect(claims.claim({ nonce: 'mismatch', digest: 'hash' })).toBe(false) + claims.register('expired', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + for (const body of [null, 1, {}, { nonce: 1 }, { nonce: 'absent' }]) { + expect(claims.claim(body)).toBe(false) + } + }) + + it('keeps pending admission bounded and never recreates canceled or consumed claims', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + claims.register('waiting', 'hash', () => 'pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe('pending') + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe(true) + expect(claims.claim({ nonce: 'waiting', digest: 'hash' })).toBe(true) + expect(claims.admit('waiting', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('canceled', 'hash', () => 'pending') + claims.cancel('canceled') + expect(claims.admit('canceled', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.register('expired', 'hash', () => 'pending') + now = 20000 + expect(claims.claim({ nonce: 'expired', digest: 'hash' })).toBe(false) + expect(claims.admit('expired', () => ({ freshSpawn: true, firstUserInputAt: null }))).toBe( + false + ) + claims.clear() + }) + + it('bounds pending claims and reclaims expired capacity without timers', () => { + let now = 0 + const claims = new OpenCodeStartupPromptClaims(() => now) + for (let index = 0; index < 129; index++) { + claims.register(String(index), 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + } + expect(claims.claim({ nonce: '128', digest: 'hash' })).toBe(false) + now = 20000 + claims.register('new', 'hash', () => ({ freshSpawn: true, firstUserInputAt: null })) + expect(claims.claim({ nonce: 'new', digest: 'hash' })).toBe(true) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-claims.ts b/src/main/opencode/opencode-startup-prompt-claims.ts new file mode 100644 index 000000000000..02fb1e078c01 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-claims.ts @@ -0,0 +1,85 @@ +import type { TerminalRunFacts } from '../runtime/terminal-run-facts' + +type PromptClaim = { + digest: string + expiresAt: number + readOwner: () => TerminalRunFacts | 'pending' | null + cleanup: () => void + expiry: ReturnType +} + +/** A one-use launch authorization; the execution owner supplies the process facts. */ +export class OpenCodeStartupPromptClaims { + private readonly pending = new Map() + + constructor(private readonly now: () => number = Date.now) {} + + register( + nonce: string, + digest: string, + readOwner: PromptClaim['readOwner'], + cleanup = () => {} + ): boolean { + const now = this.now() + for (const [key, claim] of this.pending) { + if (claim.expiresAt <= now) { + this.cancel(key) + } + } + if (this.pending.size >= 128 || this.pending.has(nonce)) { + return false + } + const expiry = setTimeout(() => this.cancel(nonce), 20000) + expiry.unref?.() + this.pending.set(nonce, { digest, readOwner, expiresAt: now + 20000, cleanup, expiry }) + return true + } + + admit(nonce: string, readOwner: PromptClaim['readOwner'], cleanup = () => {}): boolean { + const pending = this.pending.get(nonce) + if (!pending || pending.expiresAt <= this.now()) { + this.cancel(nonce) + return false + } + pending.readOwner = readOwner + pending.cleanup = cleanup + return true + } + + cancel(nonce: string): void { + const pending = this.pending.get(nonce) + this.pending.delete(nonce) + if (pending) { + clearTimeout(pending.expiry) + } + pending?.cleanup() + } + + clear(): void { + for (const nonce of this.pending.keys()) { + this.cancel(nonce) + } + } + + claim(body: unknown): boolean | 'pending' { + if (!body || typeof body !== 'object' || !('nonce' in body) || typeof body.nonce !== 'string') { + return false + } + const pending = this.pending.get(body.nonce) + if ( + !pending || + pending.expiresAt <= this.now() || + !('digest' in body) || + body.digest !== pending.digest + ) { + this.cancel(body.nonce) + return false + } + const owner = pending.readOwner() + if (owner === 'pending') { + return 'pending' + } + this.cancel(body.nonce) + return owner?.freshSpawn === true && owner.firstUserInputAt === null + } +} diff --git a/src/main/opencode/opencode-startup-prompt-installer.test.ts b/src/main/opencode/opencode-startup-prompt-installer.test.ts new file mode 100644 index 000000000000..6cc8d54824aa --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setAppEnvironment } from '../../shared/app-environment' +import { + createOpenCodeStartupPromptInstaller, + installOpenCodeStartupPromptForLaunch +} from './opencode-startup-prompt-installer' + +let root: string +let originalXdg: string | undefined +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'opencode-prompt-install-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = join(root, 'config') + setAppEnvironment({ + getPath: () => join(root, 'profile'), + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) +}) +afterEach(() => { + if (originalXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalXdg + } + rmSync(root, { recursive: true, force: true }) +}) +describe('OpenCode startup prompt installer', () => { + it('keeps a missing user config untouched and installs the launch into an owned overlay', () => { + const source = join(root, 'missing-user-config') + const env: Record = { + ORCA_OPENCODE_PLUGIN_API: 'v2', + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'private-launch', + OPENCODE_CONFIG_DIR: source, + OPENCODE_CONFIG_CONTENT: '{"model":"opencode/model"}' + } + installOpenCodeStartupPromptForLaunch(env) + expect(existsSync(source)).toBe(false) + expect(env.OPENCODE_CONFIG_DIR).toContain( + join(root, 'profile', 'opencode-startup-prompt-overlays') + ) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(env.OPENCODE_CONFIG_DIR) + expect(env.OPENCODE_CONFIG_CONTENT).toBe('{"model":"opencode/model"}') + expect( + existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-startup-prompt', 'tui.js')) + ).toBe(true) + expect(existsSync(join(env.OPENCODE_CONFIG_DIR, 'plugins', 'orca-opencode-status.js'))).toBe( + false + ) + }) + it('installs only a TUI entry without installing status hooks or a v1/server entry', () => { + const service = createOpenCodeStartupPromptInstaller(() => 'prompt source') + expect(service.buildPtyEnv('pane')).toEqual({}) + const plugins = join(root, 'config', 'opencode', 'plugins') + expect(readFileSync(join(plugins, 'orca-opencode-startup-prompt', 'tui.js'), 'utf8')).toBe( + 'prompt source' + ) + expect(existsSync(join(plugins, 'orca-opencode-startup-prompt.js'))).toBe(false) + expect(existsSync(join(plugins, 'orca-opencode-status.js'))).toBe(false) + expect(existsSync(join(root, 'profile', 'opencode-startup-prompt-hooks'))).toBe(false) + }) + it('preserves user config and plugins across source-scoped overlay refreshes', () => { + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins'), { recursive: true }) + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + writeFileSync(join(config, 'plugins', 'user.js'), 'user source') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt')) + writeFileSync( + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), + 'user collision' + ) + let source = 'first prompt source' + const service = createOpenCodeStartupPromptInstaller(() => source) + const first = service.buildPtyEnv('pane-a', config).OPENCODE_CONFIG_DIR + expect(first).toBeDefined() + source = 'next prompt source' + expect(service.buildPtyEnv('pane-b', config).OPENCODE_CONFIG_DIR).toBe(first) + if (!first) { + throw new Error('Missing overlay') + } + expect( + readFileSync(join(first, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe(source) + expect(readFileSync(join(first, 'opencode.json'), 'utf8')).toContain('user/model') + expect(readFileSync(join(first, 'plugins', 'user.js'), 'utf8')).toBe('user source') + expect( + readFileSync(join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js'), 'utf8') + ).toBe('user collision') + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-installer.ts b/src/main/opencode/opencode-startup-prompt-installer.ts new file mode 100644 index 000000000000..509d330d0f88 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-installer.ts @@ -0,0 +1,64 @@ +import { OpenCodeHookService } from './hook-service' +import { OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY } from '../../shared/opencode-startup-prompt-install' +import { join } from 'node:path' +import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { isOverlayOpenCodePluginCurrent } from '../../shared/opencode-installed-plugin' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' + +export function createOpenCodeStartupPromptInstaller(source: () => string): OpenCodeHookService { + return new OpenCodeHookService({ + pluginFileName: `${OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY}.js`, + legacyHooksDir: 'opencode-startup-prompt-hooks', + overlayDir: 'opencode-startup-prompt-overlays', + pluginSource: source, + tuiOnlyDirectory: OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY + }) +} + +const installer = createOpenCodeStartupPromptInstaller(getOpenCodeStartupPromptSource) + +export function installOpenCodeStartupPromptForLaunch( + env: Record, + restoreAfterShellStartup = true +): boolean { + const nonce = env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (!nonce || env.ORCA_OPENCODE_PLUGIN_API !== 'v2') { + return false + } + const source = env.OPENCODE_CONFIG_DIR || resolveOpenCodeConfigDirectory(env) + const overlay = installer.buildPtyEnv(nonce, source).OPENCODE_CONFIG_DIR + if ( + !overlay || + !isOverlayOpenCodePluginCurrent( + join(overlay, 'plugins', OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, 'tui.js'), + getOpenCodeStartupPromptSource() + ) + ) { + for (const key of [ + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV + ]) { + delete env[key] + } + return false + } + env.OPENCODE_CONFIG_DIR = overlay + if (restoreAfterShellStartup) { + env.ORCA_OPENCODE_CONFIG_DIR = overlay + } + return true +} + +export function ensureOpenCodeStartupPromptForLaunch(env: Record): void { + if (env[OPENCODE_STARTUP_PROMPT_NONCE_ENV] && !installOpenCodeStartupPromptForLaunch(env)) { + throw new Error('Cannot prepare OpenCode startup prompt; launch was canceled.') + } +} diff --git a/src/main/opencode/opencode-startup-prompt-owner.test.ts b/src/main/opencode/opencode-startup-prompt-owner.test.ts new file mode 100644 index 000000000000..eda73f48b553 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TerminalRunFactsRegister } from '../runtime/terminal-run-facts' +import { + reserveOpenCodeStartupPrompt, + commitPtyWithOpenCodePromptIntent +} from './opencode-startup-prompt-owner' + +const control = vi.hoisted(() => ({ + claim: (_body: unknown): boolean | 'pending' => false, + clear: () => {} +})) +const ownership = vi.hoisted(() => ({ + ptyOwnership: new Map(), + ptyIncarnationById: new Map() +})) +vi.mock('../agent-hooks/server', () => ({ + agentHookServer: { + setStartupPromptClaimListener: (claim: typeof control.claim, clear: () => void) => { + control.claim = claim + control.clear = clear + } + } +})) +vi.mock('../ipc/pty/provider/ownership-state', () => ownership) +beforeEach(() => { + control.clear() + ownership.ptyOwnership.clear() + ownership.ptyIncarnationById.clear() +}) + +function fixture() { + const facts = new TerminalRunFactsRegister() + const result = { id: 'owned', incarnationId: 'incarnation' } + let identityReady = false + let release = () => {} + const waiting = new Promise((resolve) => { + release = resolve + }) + const runtime = { + terminalRunFacts: facts, + readOpenCodeStartupPromptOwner: () => + identityReady ? facts.read(result.id, result.incarnationId) : ('pending' as const), + isPtyStopRequested: () => false, + subscribeToPtyExit: () => () => {} + } + ownership.ptyOwnership.set(result.id, null) + ownership.ptyIncarnationById.set(result.id, result.incarnationId) + const context = { + env: { + ORCA_OPENCODE_STARTUP_PROMPT_NONCE: 'nonce', + ORCA_OPENCODE_STARTUP_PROMPT_SHA256: 'digest', + ORCA_AGENT_LAUNCH_TOKEN: 'launch' + }, + deps: { runtime }, + result, + provider: { hasPty: () => true }, + args: {} + } + reserveOpenCodeStartupPrompt('nonce', 'digest') + const body = { nonce: 'nonce', digest: 'digest' } + return { + facts, + result, + context, + waiting, + release, + body, + admit: () => { + identityReady = true + } + } +} + +describe('native prompt admission after spawn commit', () => { + it('waits through delayed persistence and later runtime identity admission', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + expect(control.claim(f.body)).toBe('pending') + f.admit() + expect(control.claim(f.body)).toBe(true) + expect(control.claim(f.body)).toBe(false) + }) + + it('keeps pre-commit driving input sticky even if the draft is erased', async () => { + const f = fixture() + const committed = commitPtyWithOpenCodePromptIntent(f.context, async () => { + await f.waiting + f.facts.recordSpawnCommit(f.result) + return f.result + }) + f.facts.recordInput(f.result.id, 'driving', 'x') + f.facts.recordInput(f.result.id, 'driving', '\u007f') + expect(control.claim(f.body)).toBe('pending') + f.release() + await committed + f.admit() + expect(control.claim(f.body)).toBe(false) + }) + + it('cancels a failed commit without allowing later admission', async () => { + const f = fixture() + await expect( + commitPtyWithOpenCodePromptIntent(f.context, async () => { + throw new Error('persistence rejected') + }) + ).rejects.toThrow('persistence rejected') + f.admit() + expect(control.claim(f.body)).toBe(false) + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-owner.ts b/src/main/opencode/opencode-startup-prompt-owner.ts new file mode 100644 index 000000000000..be1dacba25db --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-owner.ts @@ -0,0 +1,111 @@ +import { agentHookServer } from '../agent-hooks/server' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { IPtyProvider, PtySpawnResult } from '../providers/types' +import { ptyIncarnationById, ptyOwnership } from '../ipc/pty/provider/ownership-state' +import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import { + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_SHA256_ENV +} from '../../shared/opencode-startup-prompt' +import { OpenCodeStartupPromptClaims } from './opencode-startup-prompt-claims' + +type OpenCodePromptRuntime = Pick< + OrcaRuntimeService, + | 'terminalRunFacts' + | 'readOpenCodeStartupPromptOwner' + | 'isPtyStopRequested' + | 'subscribeToPtyExit' +> +const claims = new OpenCodeStartupPromptClaims() + +export function reserveOpenCodeStartupPrompt(nonce: string, digest: string): boolean { + agentHookServer.setStartupPromptClaimListener( + (body) => claims.claim(body), + () => claims.clear() + ) + return claims.register(nonce, digest, () => 'pending') +} + +export async function commitPtyWithOpenCodePromptIntent( + context: { + env?: Record + spawnEnv?: Record + deps: { runtime?: OpenCodePromptRuntime } + result: PtySpawnResult + provider: Pick + args: { connectionId?: string | null } + }, + commit: () => Promise +): Promise { + const options = { + env: context.spawnEnv ?? context.env, + runtime: context.deps.runtime, + result: context.result, + provider: context.provider, + connectionId: context.args.connectionId + } + const facts = options.runtime?.terminalRunFacts + facts?.reserveSpawnCommit(options.result) + try { + const result = await commit() + bindOpenCodeStartupPromptOwner({ ...options, result }) + return result + } catch (error) { + const nonce = options.env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + if (nonce) { + claims.cancel(nonce) + } + throw error + } finally { + facts?.discardSpawnCommit(options.result) + } +} + +export function bindOpenCodeStartupPromptOwner(options: { + env: Record | undefined + result: PtySpawnResult + runtime: OpenCodePromptRuntime | undefined + provider: Pick + connectionId?: string | null +}): void { + const { env, result, runtime, provider } = options + const nonce = env?.[OPENCODE_STARTUP_PROMPT_NONCE_ENV] + const digest = env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV] + const launchToken = env?.ORCA_AGENT_LAUNCH_TOKEN + const incarnation = result.incarnationId + if ( + options.connectionId || + !runtime || + result.isReattach || + result.agentSessionEnsure?.disposition === 'adopted' || + !isPtyIncarnationId(incarnation) || + !nonce || + !digest || + !launchToken + ) { + if (nonce) { + claims.cancel(nonce) + } + return + } + let unsubscribe = () => {} + if ( + claims.admit( + nonce, + () => { + if ( + ptyOwnership.get(result.id) !== null || + ptyIncarnationById.get(result.id) !== incarnation || + provider.hasPty?.(result.id) !== true || + runtime.isPtyStopRequested(result.id) + ) { + return null + } + return runtime.readOpenCodeStartupPromptOwner(result.id, incarnation, launchToken) + }, + () => unsubscribe() + ) + ) { + unsubscribe = runtime.subscribeToPtyExit(result.id, () => claims.cancel(nonce)) + } +} diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts new file mode 100644 index 000000000000..279164adc087 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -0,0 +1,256 @@ +import { EventEmitter } from 'node:events' +import { createHash } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV +} from '../../shared/opencode-startup-prompt' +import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' + +type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } +const prompt = 'exact startup brief\nwith unicode é' +const digest = createHash('sha256').update(prompt).digest('hex') +let dir: string +let setup: PluginModule['default']['setup'] +let claim: ReturnType + +class Editor extends EventEmitter { + traits: { owner: string; role: string; capture: string[]; status?: string } = { + owner: 'opencode', + role: 'prompt', + capture: ['tab'] + } + plainText = '' + focused = true + insertText(text: string) { + this.replace(this.plainText + text) + } + replace(text: string) { + this.plainText = text + this.emit('line-info-change') + } +} + +function fixture() { + const editor = new Editor() + const input = new EventEmitter() + const memory = { settled: false, expiresAt: Date.now() + 20000 } + const route = { type: 'home' } + const agent = vi.fn((): unknown[] | undefined => [{}]) + const model = vi.fn((): unknown[] | undefined => [{}]) + const dispatch = vi.fn(() => editor.replace('')) + const ctx = { + app: { version: '2.0.16' }, + renderer: { keyInput: input, currentFocusedEditor: editor }, + storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, + keymap: { dispatch }, + ui: { router: { current: () => route } }, + location: { directory: '/private' }, + data: { location: { agent: { list: agent }, model: { list: model } } } + } + return { ctx, editor, input, memory, route, agent, model, dispatch } +} + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'orca-opencode-prompt-')) + const path = join(dir, 'prompt.mjs') + writeFileSync(path, getOpenCodeStartupPromptSource()) + const module: PluginModule = await import(pathToFileURL(path).href) + setup = module.default.setup + vi.useFakeTimers() + vi.stubEnv(OPENCODE_STARTUP_PROMPT_SHA256_ENV, digest) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_BODY_ENV, prompt) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_NONCE_ENV, 'single-use-nonce') + const endpoint = join(dir, 'endpoint.cmd') + writeFileSync( + endpoint, + 'set ORCA_AGENT_HOOK_PORT=12345\nset ORCA_AGENT_HOOK_TOKEN=private-token\nset ORCA_AGENT_HOOK_ENV=test\nset ORCA_AGENT_HOOK_VERSION=1\n' + ) + vi.stubEnv(OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, endpoint) + claim = vi.fn(async () => ({ ok: true, json: async () => ({ allowed: true }) })) + vi.stubGlobal('fetch', claim) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + rmSync(dir, { recursive: true, force: true }) +}) + +describe('installed-version native prompt intent plugin', () => { + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { + const f = fixture() + if (kind === 'dialog') { + f.editor.traits.role = 'dialog' + } + if (kind === 'shell') { + f.editor.traits.status = 'SHELL' + } + if (kind === 'autocomplete') { + f.editor.traits.capture = ['escape', 'navigate', 'submit', 'tab'] + } + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('retries only explicitly pending admission, then submits once', async () => { + claim.mockResolvedValueOnce({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(300) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(claim).toHaveBeenCalledTimes(2) + await dispose() + }) + + it('cancels pending admission on input without retrying a consumed denial', async () => { + claim.mockResolvedValue({ ok: true, json: async () => ({ allowed: false, pending: true }) }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + f.input.emit('keypress', { name: 'x' }) + await vi.advanceTimersByTimeAsync(1000) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('preserves typing that predates plugin setup without requesting owner permission', async () => { + const f = fixture() + f.editor.replace('early typing') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.editor.plainText).toBe('early typing') + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + it('waits for catalogs and dispatches only until OpenCode clears the exact draft', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.memory.settled).toBe(true) + f.editor.replace(prompt) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await dispose() + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + }) + + it.each(['keypress', 'paste'])('cancels on physical %s before catalogs finish', async (event) => { + const f = fixture() + f.agent.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + f.input.emit(event) + f.agent.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('cancels a changed draft even when it is restored before the next tick', async () => { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(100) + f.editor.replace('edited') + f.editor.replace('') + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it('cancels pending intent on route changes, expiration and disposal', async () => { + for (const reason of ['route', 'expiration', 'dispose']) { + const f = fixture() + f.model.mockReturnValue(undefined) + const dispose = await setup(f.ctx) + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiration') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + await vi.advanceTimersByTimeAsync(100) + f.model.mockReturnValue([{}]) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + expect(f.input.listenerCount('keypress')).toBe(0) + } + }) + + it('leaves unverified versions and mismatched drafts unsubmitted', async () => { + const f = fixture() + f.ctx.app.version = '2.0.17' + await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + f.ctx.app.version = '2.0.16' + f.editor.replace('another brief') + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) + + it.each(['canceled', 'unavailable'])( + 'fails closed when the execution owner is %s', + async (reason) => { + claim.mockImplementation(async () => { + if (reason === 'unavailable') { + throw new Error('contact lost') + } + return { ok: true, json: async () => ({ allowed: false }) } + }) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.dispatch).not.toHaveBeenCalled() + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + await dispose() + } + ) + + it('rechecks physical cancellation after the owner response', async () => { + const f = fixture() + claim.mockImplementation(async () => { + f.input.emit('keypress') + return { ok: true, json: async () => ({ allowed: true }) } + }) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(f.dispatch).not.toHaveBeenCalled() + await dispose() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts new file mode 100644 index 000000000000..d6df936f52db --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -0,0 +1,117 @@ +import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + OPENCODE_STARTUP_PROMPT_BODY_ENV +} from '../../shared/opencode-startup-prompt' + +export function getOpenCodeStartupPromptSource(): string { + return String.raw` +const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +async function claimStartupPrompt(nonce, digest, endpoint) { + const { readFile, stat } = await import("node:fs/promises"); + if ((await stat(endpoint)).size > 4096) return false; + const coords = parseEndpoint(await readFile(endpoint, "utf8")); + const port = Number(coords.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return false; + const response = await fetch("http://127.0.0.1:" + port + "${OPENCODE_STARTUP_PROMPT_CLAIM_PATH}", { + method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, + body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) + }); + if (!response.ok) return false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; +} +async function submitStartupPrompt(ctx) { + const noop = async () => {}; + const digest = process.env.${OPENCODE_STARTUP_PROMPT_SHA256_ENV}; + const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; + const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; + const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; + if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + const input = ctx.renderer?.keyInput; + if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || + typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || + typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.agent?.list !== "function" || + typeof ctx.data?.location?.model?.list !== "function") return noop; + const [memory, setMemory] = ctx.storage.memory("startup-prompt", { + initial: { settled: false, expiresAt: Date.now() + 20000 } + }); + if (memory.settled) return noop; + let timer, editor, seen = false, disposed = false, createHash, claimed = false, claiming = false, populated = false; + const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && + candidate.traits.role === "prompt" && !candidate.traits.status && + candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; + const matches = (candidate) => typeof candidate?.plainText === "string" && + createHash("sha256").update(candidate.plainText).digest("hex") === digest; + const cleanup = () => { + clearInterval(timer); + input.off("keypress", cancel); + input.off("paste", cancel); + editor?.off("line-info-change", changed); + }; + const settle = () => { + setMemory((draft) => { draft.settled = true; }); + cleanup(); + }; + const cancel = () => settle(); + // OpenCode clears the editor before awaiting session creation; never retry a restored failure. + const changed = () => { if (seen && editor.plainText !== (populated ? prompt : "")) settle(); }; + input.on("keypress", cancel); + input.on("paste", cancel); + const dispose = async () => { disposed = true; settle(); }; + try { + ({ createHash } = await import("node:crypto")); + if (createHash("sha256").update(prompt).digest("hex") !== digest) { await dispose(); return noop; } + if (memory.settled) return dispose; + timer = setInterval(async () => { + if (disposed || memory.settled) return; + try { + if (Date.now() >= memory.expiresAt || ctx.ui.router.current()?.type !== "home") return settle(); + const current = ctx.renderer.currentFocusedEditor; + if (!isComposer(current)) { if (seen) settle(); return; } + if (editor !== current) { + if (seen) return settle(); + editor?.off("line-info-change", changed); + editor = current; + editor?.on("line-info-change", changed); + } + if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; + if (editor.plainText !== (populated ? prompt : "")) return settle(); + seen = true; + const agents = ctx.data.location.agent.list(ctx.location); + const models = ctx.data.location.model.list(ctx.location); + if (!editor.focused || !agents?.length || !models?.length) return; + if (claiming) return; + if (!claimed) { + claiming = true; + const allowed = await claimStartupPrompt(nonce, digest, endpoint); + claiming = false; + if (allowed === "pending") return; + if (!allowed) return settle(); + claimed = true; + } + if (disposed || memory.settled || Date.now() >= memory.expiresAt || + ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || + !editor.focused || !isComposer(editor) || editor.plainText !== (populated ? prompt : "")) return settle(); + if (!populated) { + populated = true; + editor.insertText(prompt); + if (memory.settled || !matches(editor)) return settle(); + } + ctx.keymap.dispatch("prompt.submit"); + } catch { settle(); } + }, 100); + timer.unref?.(); + return dispose; + } catch { + settle(); + return noop; + } +} +export default { id: "orca-opencode-startup-prompt", setup: submitStartupPrompt }; +`.trimStart() +} diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde812..fd6c39bf510d 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index ad9f51e7ad63..a71307a8df56 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -151,6 +151,7 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} + let removeStatusHookSettingsListener = (): void => {} registerCleanup(async () => { try { await rpc?.stop() @@ -167,6 +168,7 @@ async function startOrcadRuntime( // orcad restart goes back to killing every running terminal. await stopOrcadDaemon() } finally { + removeStatusHookSettingsListener() uninstallObservedStatusIdentity() uninstallHookStatusRepublish() agentHookServer.stop() @@ -194,9 +196,17 @@ async function startOrcadRuntime( uninstallObservedStatusIdentity = agentHookServer.subscribeEnrichedStatus((enriched) => observedStatusCapture.observe(enriched) ) - if (isAgentStatusHooksEnabled(profileStore.getSettings())) { - await agentHookServer.start({ env: 'production', userDataPath: runtimeUserDataPath }) - } + await agentHookServer.start({ + env: 'production', + userDataPath: runtimeUserDataPath, + statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) + }) + + removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 530d06f4ee8c..41bba43d4cf4 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -14,6 +15,9 @@ const state = vi.hoisted(() => ({ controller: null as RuntimeMobileNotificationController | null, registry: null as DeviceRegistry | null, rpcStarted: false, + onSettingsChanged: vi.fn(), + removeSettingsListener: vi.fn(), + startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), send: vi.fn(async () => ({ ok: true, results: [] })) @@ -26,7 +30,7 @@ vi.mock('./orcad-app-paths', () => ({ vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider })) vi.mock('./orcad-instance-lock', () => ({ acquireOrcadInstanceLock: () => ({ release() {} }) })) vi.mock('./orcad-daemon-supervision', () => ({ - startOrcadDaemon: async () => {}, + startOrcadDaemon: state.startDaemon, stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) @@ -44,6 +48,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ createOrcadProfileStateStartup: async () => ({ store: { getSettings: () => ({}), + onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} }, @@ -124,6 +129,10 @@ vi.mock('../runtime/push/push-gateway-client', () => ({ } })) +beforeEach(() => { + state.onSettingsChanged.mockReturnValue(state.removeSettingsListener) +}) + afterEach(() => { rmSync(state.root, { recursive: true, force: true }) vi.clearAllMocks() @@ -176,6 +185,11 @@ it('starts push after RPC identity is available and stops dispatch on shutdown', expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() expect(state.controller.getListenerCount()).toBe(0) + expect(state.rpcStarted).toBe(false) + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + await host.stop() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() expect(await state.controller.registerPushDevice({} as never)).toMatchObject({ registered: false }) @@ -189,3 +203,14 @@ it('releases admission when host setup fails before a runtime exists', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +it('unsubscribes settings when daemon startup fails after hook setup', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-daemon-failure-')) + state.startDaemon.mockRejectedValueOnce(new Error('daemon setup failed')) + const { startOrcad } = await import('./orcad-entry') + await expect(startOrcad()).rejects.toThrow('daemon setup failed') + expect(state.onSettingsChanged).toHaveBeenCalledOnce() + expect(state.removeSettingsListener).toHaveBeenCalledOnce() + expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) + acquireProfileStateMaintenance(state.root).release() +}) diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b66..4eb5dc45a60d 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/main/pty/overlay-mirror.test.ts b/src/main/pty/overlay-mirror.test.ts index 5499d2264225..1a741552719c 100644 --- a/src/main/pty/overlay-mirror.test.ts +++ b/src/main/pty/overlay-mirror.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import type * as NodePath from 'node:path' @@ -19,6 +19,33 @@ afterEach(() => { }) describe('safeRemoveOverlay', () => { + it('keeps missing owned paths a silent no-op', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-missing-')) + tempRoots.push(root) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(root, 'missing', 'leaf'), join(root, 'missing')) + expect(warn).not.toHaveBeenCalled() + }) + + it('refuses cleanup through an intermediate symlink and unlinks only a leaf symlink', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-overlay-symlink-')) + tempRoots.push(root) + const overlay = join(root, 'overlay') + const outside = join(root, 'outside') + mkdirSync(overlay) + mkdirSync(outside) + const sentinel = join(outside, 'keep.txt') + writeFileSync(sentinel, 'private sentinel') + const linked = join(overlay, 'linked') + symlinkSync(outside, linked, process.platform === 'win32' ? 'junction' : 'dir') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + safeRemoveOverlay(join(linked, 'keep.txt'), overlay) + expect(existsSync(sentinel)).toBe(true) + safeRemoveOverlay(linked, overlay) + expect(existsSync(linked)).toBe(false) + expect(existsSync(sentinel)).toBe(true) + }) + it('removes valid overlay children whose names start with dot-dot', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-overlay-root-')) tempRoots.push(root) diff --git a/src/main/pty/overlay-mirror.ts b/src/main/pty/overlay-mirror.ts index ec1b99d2b92c..6a35844f8717 100644 --- a/src/main/pty/overlay-mirror.ts +++ b/src/main/pty/overlay-mirror.ts @@ -138,6 +138,33 @@ export function safeRemoveTree(path: string): void { } } +// Why: cleanup must not traverse a symlink parent inside the owned overlay. +function hasSafeOverlayAncestors(root: string, relativeTarget: string): boolean { + let current = root + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + const segments = relativeTarget.split(sep).filter(Boolean) + for (const [index, segment] of segments.entries()) { + current = join(current, segment) + if (index === segments.length - 1) { + break + } + try { + if (lstatSync(current).isSymbolicLink()) { + return false + } + } catch (error) { + return !!error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT' + } + } + return true +} + // Why: last-line guard against an overlay-root constant ever being // mis-resolved. Any caller that points safeRemoveTree at a path outside its // designated overlay root is refused so a misconfiguration cannot turn into @@ -147,7 +174,13 @@ export function safeRemoveOverlay(overlayDir: string, overlayRoot: string): void const resolvedRoot = resolve(overlayRoot) const resolvedTarget = resolve(overlayDir) const rel = relative(resolvedRoot, resolvedTarget) - if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + if ( + rel === '' || + rel === '..' || + rel.startsWith(`..${sep}`) || + isAbsolute(rel) || + !hasSafeOverlayAncestors(resolvedRoot, rel) + ) { console.warn( `[overlay-mirror] refusing to remove overlay outside root: target=${resolvedTarget} root=${resolvedRoot}` ) diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..c7e2132fa288 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:40:33.073309Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: answered like a hidden Orca pane (silent on OSC colour queries), as a background worker pane is. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 42.8 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..0701a967c642 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":42761.8,"chunks":[[785.0,205],[786.9,71],[791.9,8],[793.5,57],[793.9,32],[794.0,16],[794.0,16],[794.1,24],[794.2,24],[794.2,24],[794.3,36],[794.4,24],[794.5,24],[795.2,1024],[795.8,1024],[796.3,1024],[796.7,1024],[796.9,1024],[797.2,961],[811.9,1024],[812.5,1024],[813.1,1024],[813.6,1024],[814.1,1024],[814.7,937],[2523.9,1024],[2524.9,1024],[2525.2,1024],[2525.5,1024],[2525.8,1024],[2526.1,1024],[2526.3,353],[2541.6,13],[2609.5,934],[2610.0,938],[2610.6,871],[2611.3,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..903a8f0f6968 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json new file mode 100644 index 000000000000..b05bbf658fe7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:39:12.532885Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: the slowest recorded boot: 3.9 s of silence after bracketed paste is enabled, and the longest gap (161 ms) between the bare OpenCode title and the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 76.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json new file mode 100644 index 000000000000..25aa23bb5bd3 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":76886.4,"chunks":[[2454.9,276],[2478.3,8],[2513.8,1024],[2518.9,1024],[2531.6,1024],[2539.1,1024],[2581.2,1024],[2583.9,1024],[2587.4,411],[2593.0,1024],[2602.3,1024],[2607.8,1024],[2621.8,1024],[2622.5,1024],[2623.3,937],[6479.2,1024],[6485.0,1024],[6487.5,1024],[6490.0,1024],[6499.8,1024],[6502.7,1024],[6509.0,353],[6538.4,13],[6685.0,934],[6686.4,938],[6697.9,871],[6699.1,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt new file mode 100644 index 000000000000..748053e1c3ee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json new file mode 100644 index 000000000000..e0ad565f9350 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:33:44.169427Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: first launch on an empty home: no first-run dialog appears before the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 14.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json new file mode 100644 index 000000000000..9c1d94e0a7a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":14932.3,"chunks":[[894.6,205],[897.3,71],[906.1,8],[909.0,57],[909.3,32],[909.4,16],[909.5,16],[909.6,24],[909.6,24],[909.8,24],[909.9,36],[910.0,24],[910.1,24],[911.8,134],[915.0,1024],[916.4,1024],[917.0,1024],[917.5,1024],[918.0,1024],[918.6,1024],[933.4,1024],[934.1,1024],[934.7,1024],[935.3,1024],[935.8,1024],[936.4,937],[2731.2,1024],[2731.8,1024],[2732.2,1024],[2732.7,1024],[2733.1,1024],[2734.0,1024],[2734.4,353],[2766.9,13],[2847.0,934],[2847.6,938],[2848.1,871],[2848.6,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt new file mode 100644 index 000000000000..2baf054700fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..5399222bf7ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:50:47.405221Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.18 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: OpenCode 2 answered like a hidden Orca pane; show-cursor repeats on every frame after the box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 41.5 s; no bytes after the last chunk. Run as `opencode --standalone` from a 2.0.18 binary; Orca's opencode2 row launches `opencode2 --standalone`. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..2a95872c1809 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":41502.8,"chunks":[[307.5,280],[316.3,1024],[316.7,1024],[317.0,1024],[317.4,1024],[317.7,1024],[318.0,1022],[318.3,164],[318.3,24],[318.3,24],[326.8,1024],[327.5,1024],[328.0,1024],[328.6,1024],[329.1,1024],[329.7,937],[1339.7,1024],[1340.4,1024],[1341.1,1024],[1341.8,1024],[1342.5,1024],[1343.1,1024],[1343.8,353],[1348.0,13],[1387.9,934],[1388.4,938],[1388.7,832],[1418.2,483],[1436.2,483],[1453.8,488],[1471.8,497],[1489.7,498],[1508.0,504],[1525.8,504],[1543.2,504],[1560.9,504]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..b2b9cd01dfee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/USER1/orca-qa/b9b-capture/workshift+tab agentsctrl+p commands2.0.18]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json new file mode 100644 index 000000000000..bc852df43bfe --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-31: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "32-36: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-31: verdict=pending:closed wait=pending", + "32-36: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json new file mode 100644 index 000000000000..e81353565e88 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-31: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "32-36: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-31: verdict=pending:open wait=ready@poll", + "32-36: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json new file mode 100644 index 000000000000..817fbfc423d1 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-26: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:closed wait=pending", + "22-26: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json new file mode 100644 index 000000000000..3d6fbbc85f3a --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-boot-slow@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "22-26: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:open wait=ready@poll", + "22-26: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json new file mode 100644 index 000000000000..4935173109db --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-32: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "33-37: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-32: verdict=pending:closed wait=pending", + "33-37: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json new file mode 100644 index 000000000000..68787dfeac61 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-1-18-32-timed-first-launch@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-32: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "33-37: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-32: verdict=pending:open wait=ready@poll", + "33-37: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json new file mode 100644 index 000000000000..1796a3a6e6be --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-22: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "23-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-22: verdict=pending:closed wait=pending", + "23-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json new file mode 100644 index 000000000000..7cdd73d939ee --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-18-timed-boot-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-22: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "23-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-22: verdict=pending:open wait=ready@poll", + "23-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35364709505d..4280bbc4050e 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -22,6 +22,41 @@ export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +export type TimedRuntimeFixture = { + /** The recorded PTY reads, in order; they concatenate to the whole `.txt`. */ + chunks: string[] + /** ms since spawn at which each read arrived. */ + times: number[] + promptSentAtMs?: number + /** When recording stopped; no bytes arrived between the last read and this. */ + recordedUntilMs?: number +} + +/** `.timing.json` holds each read as [ms since spawn, UTF-16 length]. */ +export function readTimedRuntimeFixture(name: string): TimedRuntimeFixture { + const data = readRuntimeFixture(name) + const timing: { + chunks: [number, number][] + promptSentAtMs?: number + recordedUntilMs?: number + } = JSON.parse(readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + if (offset !== data.length) { + throw new Error(`${name}.timing.json covers ${offset} of ${data.length} chars`) + } + return { + chunks, + times: timing.chunks.map(([at]) => at), + ...(timing.promptSentAtMs !== undefined ? { promptSentAtMs: timing.promptSentAtMs } : {}), + ...(timing.recordedUntilMs !== undefined ? { recordedUntilMs: timing.recordedUntilMs } : {}) + } +} + /** Resizes the grid before chunk `atChunk`, as a PTY resize landing mid-paint would. */ export type TranscriptReplayResize = { atChunk: number; cols: number; rows: number } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts new file mode 100644 index 000000000000..8fbbf19d552d --- /dev/null +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -0,0 +1,191 @@ +/** + * The draft-paste scanner must answer the same way however the PTY splits a stream into reads. + * Every committed capture whose signal revokes its anchor is replayed whole, one char at a time, + * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on + * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and + * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +type Walk = { anchor: string; end: string; marker: string } + +const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': { + anchor: '\x1b[?2004h', + end: '\x1b[?2004l', + marker: '\x1b[?25h' + }, + 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } +} + +// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +const ZSH_LAUNCH_PROLOGUE = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + +const OPENCODE_TIMED = [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch', + 'opencode-2-0-18-timed-boot-hidden-pane' +] + +type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } + +function readPtyTranscript(name: string): string { + return readFileSync( + join(__dirname, '..', 'daemon', '__fixtures__', 'pty-transcripts', name), + 'utf8' + ) +} + +function grokCase(name: string, trace: GrokStartupTraceChunk[]): Case { + const reads = trace.map((chunk) => chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + return { name, signal: 'grok-composer-prompt', data: reads.join(''), reads } +} + +const CASES: Case[] = [ + grokCase('grok 1.0.0 alt screen', GROK_STARTUP_PTY_TRACE), + grokCase('grok 1.0.0 inline', GROK_INLINE_STARTUP_PTY_TRACE), + { + name: 'dsh-tui-ready-no-key', + signal: 'dsh-composer-prompt', + data: readRuntimeFixture('dsh-tui-ready-no-key') + }, + { + name: 'zcode-composer-ready', + signal: 'zcode-composer-prompt', + data: readRuntimeFixture('zcode-composer-ready') + }, + { + name: 'opencode (untimed pty transcript)', + signal: 'render-cursor-after-bracketed-paste', + data: readPtyTranscript('opencode.txt') + }, + ...OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch`, + signal: 'render-cursor-after-bracketed-paste', + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) +] + +/** End offset of the first marker seen while the anchor is held, walking the whole string. */ +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { + let cursor = 0 + for (;;) { + const enter = data.indexOf(anchor, cursor) + if (enter === -1) { + return null + } + cursor = enter + anchor.length + const leave = data.indexOf(end, cursor) + const found = data.indexOf(marker, cursor) + if (found !== -1 && (leave === -1 || found + marker.length <= leave)) { + return found + marker.length + } + if (leave === -1) { + return null + } + cursor = leave + end.length + } +} + +/** [start, end) of the first read the scanner reports ready on, or null. */ +function scanReadyRead(signal: DraftPasteReadySignal, reads: string[]): [number, number] | null { + const scanner = createDraftPasteReadyScanner(signal) + let offset = 0 + for (const read of reads) { + if (scanner.observe(read).ready) { + return [offset, offset + read.length] + } + offset += read.length + } + return null +} + +function splitAt(data: string, sizes: () => number): string[] { + const reads: string[] = [] + for (let offset = 0; offset < data.length;) { + const size = sizes() + reads.push(data.slice(offset, offset + size)) + offset += size + } + return reads +} + +// mulberry32: a fixed seed keeps every chunking reproducible. +function seededSizes(seed: number, max: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return 1 + Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * max) + } +} + +function chunkings(testCase: Case): Map { + const { data } = testCase + const result = new Map([ + ['whole', [data]], + ['one char per read', splitAt(data, () => 1)] + ]) + if (testCase.reads) { + result.set('recorded reads', testCase.reads) + } + for (let seed = 0; seed < 12; seed += 1) { + const max = seed % 2 === 0 ? 16 : 2048 + result.set(`random chunking ${seed}`, splitAt(data, seededSizes(seed + 1, max))) + } + return result +} + +describe('draft-paste readiness does not depend on how the stream is chunked', () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { + const walk = WALKS[testCase.signal] + if (!walk) { + throw new Error(`no reference walk for ${testCase.signal}`) + } + const expected = walkReadyEnd(testCase.data, walk) + for (const [label, reads] of chunkings(testCase)) { + const readyRead = scanReadyRead(testCase.signal, reads) + if (expected === null) { + expect(readyRead, label).toBeNull() + continue + } + expect(readyRead, label).not.toBeNull() + const [start, end] = readyRead! + expect( + start < expected && expected <= end, + `${label}: [${start}, ${end}) vs ${expected}` + ).toBe(true) + } + }) + + it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { + const walk = WALKS['render-cursor-after-bracketed-paste']! + const data = readTimedRuntimeFixture(name).chunks.join('') + const alone = walkReadyEnd(data, walk) + expect(alone).not.toBeNull() + expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 000000000000..7f8e7a18fa15 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + readRuntimeFixture, + readTimedRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + getLaunchedAgentReadinessLane, + type LaunchedAgentReadinessLane +} from './launched-agent-composer-readiness' + +// Why a full Record: adding a TuiAgent fails to compile here until someone decides whether its +// worker start waits for a captured input-box marker, and a row that gains or loses evidence +// shows up in review. +const EXPECTED_LANES: Record = { + zcode: 'composer-marker', + opencode: 'composer-marker', + opencode2: 'composer-marker', + // mimo-code shares OpenCode's signal by parity only; no mimo boot has been recorded. + 'mimo-code': 'tui-idle', + // Grok's inline mode never fires its marker; its marker is not proven at the box for every mode. + grok: 'tui-idle', + dsh: 'tui-idle', + codex: 'tui-idle', + cursor: 'tui-idle', + pi: 'tui-idle', + omp: 'tui-idle', + droid: 'tui-idle', + hermes: 'tui-idle', + devin: 'tui-idle', + claude: 'tui-idle', + 'claude-agent-teams': 'tui-idle', + openclaude: 'tui-idle', + gemini: 'tui-idle', + antigravity: 'tui-idle', + aider: 'tui-idle', + openclaw: 'tui-idle', + copilot: 'tui-idle', + muse: 'tui-idle', + qoder: 'tui-idle', + codebuddy: 'tui-idle', + autohand: 'tui-idle', + ante: 'tui-idle', + trae: 'tui-idle', + 'prime-agent': 'tui-idle', + goose: 'tui-idle', + amp: 'tui-idle', + kilo: 'tui-idle', + kiro: 'tui-idle', + crush: 'tui-idle', + aug: 'tui-idle', + cline: 'tui-idle', + codebuff: 'tui-idle', + freebuff: 'tui-idle', + 'command-code': 'tui-idle', + continue: 'tui-idle', + kimi: 'tui-idle', + 'mistral-vibe': 'tui-idle', + 'qwen-code': 'tui-idle', + rovo: 'tui-idle' +} + +const FIXTURES = join(__dirname, '__fixtures__') +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => + (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) +) + +function isTuiAgent(agent: string): agent is TuiAgent { + return agent in TUI_AGENT_CONFIG +} + +function readSignal(agent: string) { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + if (!signal) { + throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) + } + return signal +} + +/** Index of the first read the scanner reports ready on, or -1. */ +function firstReadyRead(agent: string, reads: readonly string[]): number { + const scanner = createDraftPasteReadyScanner(readSignal(agent)) + return reads.findIndex((read) => scanner.observe(read).ready) +} + +function readReads(capture: string): string[] { + if (existsSync(join(FIXTURES, `${capture}.timing.json`))) { + return readTimedRuntimeFixture(capture).chunks + } + const data = readRuntimeFixture(capture) + const reads: string[] = [] + for (let offset = 0; offset < data.length; offset += 4096) { + reads.push(data.slice(offset, offset + 4096)) + } + return reads +} + +describe('which lane a freshly launched worker waits on', () => { + it.each(Object.entries(EXPECTED_LANES))('%s: %s', (agent, lane) => { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + expect(getLaunchedAgentReadinessLane(agent)).toBe(lane) + }) +}) + +describe('every capture a row cites proves its input-box marker', () => { + it('cites at least the rows worker start moved', () => { + expect(CITED_CAPTURES.map(([agent]) => agent)).toEqual( + expect.arrayContaining(['zcode', 'opencode', 'opencode2']) + ) + }) + + it.each(CITED_CAPTURES)('%s: %s is committed with its sidecar', (_agent, capture) => { + expect(existsSync(join(FIXTURES, `${capture}.txt`))).toBe(true) + const meta: { platform?: string; command?: string[]; note?: string } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + expect(meta.platform).toEqual(expect.any(String)) + expect(meta.command?.length).toBeGreaterThan(0) + expect(meta.note).toEqual(expect.any(String)) + }) + + it.each(CITED_CAPTURES)("%s: the row's current signal fires on %s", (agent, capture) => { + expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) + }) + + it.each( + CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') + )( + '%s: the first ready read in %s is the one that shows the input box', + async (agent, capture) => { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + const { chunks } = readTimedRuntimeFixture(capture) + const data = chunks.join('') + // OpenCode paints its box in one synchronized update, shown when that update ends. + const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + let boxRead = -1 + for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { + end += chunks[index].length + boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 + } + let placeholderRead = -1 + let read = 0 + for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { + if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { + placeholderRead = read + break + } + read += 1 + } + expect(placeholderRead).toBeGreaterThanOrEqual(0) + expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) + expect(firstReadyRead(agent, chunks)).toBe(boxRead) + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 000000000000..dedfeba8823e --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,37 @@ +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { OrcaRuntimeService } from './orca-runtime' + +export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' +> + +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' + ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) +} + +export function waitForWorkerAgentReady( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + args: { agent: TuiAgent | undefined; reusesTerminal: boolean; timeoutMs: number } +): Promise { + // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + return args.agent && !args.reusesTerminal + ? waitForLaunchedAgentComposer(runtime, handle, args.agent, args.timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: args.timeoutMs }) +} diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..52aa2f4da86e --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,101 @@ +/** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title +// and the launcher's cursor toggle are synthetic. +const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] +] + +/** The read that ends the synchronized update drawing OpenCode's input box. */ +function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) +} + +async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { composer: number | null; tuiIdle: number | null } = { + composer: null, + tuiIdle: null + } + let read = -1 + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => (settledAt.composer ??= read)) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { settledAt, composer, boxRead: boxRead(chunks) } +} + +describe('an OpenCode worker gets its task only once its input box exists', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles on the read that shows the box', + async (name, agent) => { + const { settledAt, composer, boxRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 2f7e59a6d1f8..0b50aa9a5d77 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, @@ -182,7 +183,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( @@ -198,6 +199,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 5a471276d85d..cdfb66c95d55 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -244,6 +244,25 @@ export class OrcaRuntimeWithRuntimeId { readonly terminalRunFacts = new TerminalRunFactsRegister() + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + /** Both spawn-commit funnels report each committed process here, once. */ noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 3ba056ef1e60..7f32d92508c2 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -43,6 +43,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }], ['muse-', { agent: 'muse', foregroundProcess: 'muse' }], ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], + ['opencode-2-', { agent: 'opencode2', foregroundProcess: 'opencode' }], + ['opencode-1-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], ['qoder-', { agent: 'qoder', foregroundProcess: 'qodercli' }], ['zcode-', { agent: 'zcode', foregroundProcess: 'zcode' }] diff --git a/src/main/runtime/readiness-census.test.ts b/src/main/runtime/readiness-census.test.ts index 1aab4d59b6cf..e757acdcb7e8 100644 --- a/src/main/runtime/readiness-census.test.ts +++ b/src/main/runtime/readiness-census.test.ts @@ -23,14 +23,26 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { describeCensusDiff, runLengthDecode, runLengthEncode } from './readiness-census-baseline' import { CENSUS_AGENTS } from './readiness-census-synthetic-matrix' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { CENSUS_PANES, + CENSUS_TRANSCRIPTS, CENSUS_SHARD_COUNT, censusPaneSubject, censusShard } from './readiness-census-transcript-catalog' describe('readiness census coverage', () => { + it('assigns every cited composer recording to its launch agent', () => { + for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG)) { + for (const name of config.composerReadyCaptures ?? []) { + const transcript = CENSUS_TRANSCRIPTS.find((recording) => recording.name === name) + expect(transcript, name).toBeDefined() + expect(transcript?.agent, name).toBe(agent) + } + } + }) + it('replays every pane in exactly one shard', () => { const sharded = Array.from({ length: CENSUS_SHARD_COUNT }, (_, index) => censusShard(index + 1) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts new file mode 100644 index 000000000000..d36fbd98a69a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-attachment-authority.ts @@ -0,0 +1,35 @@ +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { OrchestrationDb } from '../../../../orchestration/db' +import type { FederationEffect } from './federation-effects' +import type { WorkerSetupReceipt } from '../worker/worker-topology' + +export function prepareFederatedAttachmentAuthority(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + terminalHandle: string + worktreeId: string + setup: WorkerSetupReceipt + effects: FederationEffect[] + reusesTerminal: boolean +}): void { + const { runtime, terminalHandle } = args + const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) + const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) + const processIncarnation = + authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle) + if (!paneKey || !processIncarnation) { + throw new Error('stable_pane_required') + } + args.db.prepareRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + paneKey, + processIncarnation, + worktreeId: args.worktreeId, + terminalHandle, + setupState: args.setup.state, + effects: args.effects, + hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, + terminalOwnership: args.reusesTerminal ? 'external' : 'created' + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts new file mode 100644 index 000000000000..4a6da545fbcd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +const HANDLE = 'term_windows_worker' +const WORKTREE = 'repo::windows-worktree' + +// Why: `worker-start --on ` launches the agent through this attach on the worker host, so +// it must wait for the same input box a local start waits for, or the task is typed too early. +describe('federated worker first dispatch readiness', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const db of databases.splice(0)) { + db.close() + } + vi.restoreAllMocks() + }) + + function createWorkerHost(): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + return runtime + } + + async function attach( + runtime: OrcaRuntimeService, + target: { agent: string } | { terminal: string } + ): Promise { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + const placement = + 'agent' in target + ? { worktree: 'new-top-level', repo: 'windows-repo', name: 'remote-worker', ...target } + : { worktree: WORKTREE, ...target } + return await method.handler( + method.params!.parse({ + runId: 'run-home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote worker', + protocolVersion: 3, + ...placement + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_remote', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_payload' + } + } + ) + } + + it.each(['opencode', 'opencode2', 'zcode'] as const)( + 'a %s terminal this attach created waits for its input box, not tui-idle', + async (agent) => { + const runtime = createWorkerHost() + + await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + HANDLE, + agent, + resolveWorkerStartReadinessTimeoutMs(undefined) + ) + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + const runtime = createWorkerHost() + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. + { id: WORKTREE } as never + ) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + + await expect(attach(runtime, { terminal: HANDLE })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + }) + + it('fails at agent_readiness without typing the task when the input box never appears', async () => { + const runtime = createWorkerHost() + vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: 'timeout' + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index ee0033e8fbdd..cc2e0f8f5508 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { @@ -27,6 +28,7 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' +import { prepareFederatedAttachmentAuthority } from './federation-attachment-authority' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ @@ -217,8 +219,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } persistFederatedReadinessStage(setupStage) failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', + const wait = await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) @@ -232,23 +235,15 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ : `Agent did not become ready (${wait.status}).` ) } - const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) - const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) - const processIncarnation = - authority?.processIncarnation ?? runtime.getTerminalProcessIncarnation(terminalHandle) - if (!paneKey || !processIncarnation) { - throw new Error('stable_pane_required') - } - db.prepareRemoteAttachmentAuthority({ + prepareFederatedAttachmentAuthority({ + runtime, + db, dispatchId: params.dispatchId, - paneKey, - processIncarnation, worktreeId: worktree.id, terminalHandle, - setupState: setup.state, + setup, effects, - hostScope: authority?.hostScope ? JSON.stringify(authority.hostScope) : null, - terminalOwnership: params.terminal ? 'external' : 'created' + reusesTerminal: Boolean(params.terminal) }) failedStage = 'dispatch_input' const prompt = await runtime.sendTerminalAgentPrompt( diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f276dc736cb3..c6d7ef96f684 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,4 +1,5 @@ import { resolveWorkerConfiguredAgentParams } from './worker-configured-agent-preflight' +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -208,17 +209,11 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 30d32c2ede83..9421596669d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -1,28 +1,38 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('ZCode first dispatch readiness', () => { const h = createOrchestrationWorkerReleaseHarness() afterEach(() => h.cleanup()) - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) it('keeps reused terminals on the normal idle wait', async () => { h.setup() diff --git a/src/main/runtime/terminal-run-facts.test.ts b/src/main/runtime/terminal-run-facts.test.ts index 85460b9fc27c..1966a4059490 100644 --- a/src/main/runtime/terminal-run-facts.test.ts +++ b/src/main/runtime/terminal-run-facts.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { TerminalRunFactsRegister } from './terminal-run-facts' describe('terminal run facts', () => { + it('keeps driving input before publication across the exact reserved commit', () => { + const facts = new TerminalRunFactsRegister() + facts.recordInput('pending', 'driving', 'x', 100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-1' }) + expect(facts.read('pending', 'inc-1').firstUserInputAt).toBe(100) + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + facts.recordInput('pending', 'driving', 'x', 200) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-2' }) + expect(facts.read('pending', 'inc-2').firstUserInputAt).toBe(200) + facts.delete('pending') + facts.reserveSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + facts.recordSpawnCommit({ id: 'pending', incarnationId: 'inc-3' }) + expect(facts.read('pending', 'inc-3').firstUserInputAt).toBeNull() + }) it('reads a run main never saw committed as not fresh', () => { expect(new TerminalRunFactsRegister().read('pty-1', 'inc-1')).toEqual({ freshSpawn: false, diff --git a/src/main/runtime/terminal-run-facts.ts b/src/main/runtime/terminal-run-facts.ts index b20f64985e4c..b1c029a767c1 100644 --- a/src/main/runtime/terminal-run-facts.ts +++ b/src/main/runtime/terminal-run-facts.ts @@ -42,12 +42,38 @@ export class TerminalRunFactsRegister { private readonly runsByPtyId = new Map() // Why apart from the run record: input must count on a PTY main adopted without a commit. private readonly lastInputAtByPtyId = new Map() + private readonly pendingByPtyId = new Map< + string, + { incarnationId: string | null; firstInputAt: number | null } + >() + + reserveSpawnCommit(commit: TerminalSpawnCommit): void { + if (!commit.incarnationId) { + return + } + const prior = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.set(commit.id, { + incarnationId: commit.incarnationId, + firstInputAt: + prior?.incarnationId === null || prior?.incarnationId === commit.incarnationId + ? prior.firstInputAt + : null + }) + } + + discardSpawnCommit(commit: TerminalSpawnCommit): void { + if (this.pendingByPtyId.get(commit.id)?.incarnationId === (commit.incarnationId ?? null)) { + this.pendingByPtyId.delete(commit.id) + } + } /** Once per process: a re-registration of the same incarnation keeps its facts, and so does a * reattach or adoption of the running process unless its incarnation shows another process. */ recordSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { const incarnationId = commit.incarnationId ?? null const previous = this.runsByPtyId.get(commit.id) + const pending = this.pendingByPtyId.get(commit.id) + this.pendingByPtyId.delete(commit.id) if (incarnationId !== null && previous?.incarnationId === incarnationId) { return } @@ -60,7 +86,11 @@ export class TerminalRunFactsRegister { this.runsByPtyId.set(commit.id, { incarnationId, spawnOrigin: origin === 'spawn' && commit.coldRestore !== undefined ? 'cold-restore' : origin, - firstUserInputAt: sameProcess ? (previous?.firstUserInputAt ?? null) : null + firstUserInputAt: sameProcess + ? (previous?.firstUserInputAt ?? null) + : pending?.incarnationId === incarnationId + ? pending.firstInputAt + : null }) } @@ -73,6 +103,14 @@ export class TerminalRunFactsRegister { } this.lastInputAtByPtyId.set(ptyId, now) const run = this.runsByPtyId.get(ptyId) + if (inputKind === 'driving') { + const pending = this.pendingByPtyId.get(ptyId) + if (pending) { + pending.firstInputAt ??= now + } else if (!run) { + this.pendingByPtyId.set(ptyId, { incarnationId: null, firstInputAt: now }) + } + } if (run && inputKind === 'driving') { run.firstUserInputAt ??= now } @@ -99,5 +137,6 @@ export class TerminalRunFactsRegister { delete(ptyId: string): void { this.runsByPtyId.delete(ptyId) this.lastInputAtByPtyId.delete(ptyId) + this.pendingByPtyId.delete(ptyId) } } diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts index 03cef4732991..b5cb284db088 100644 --- a/src/main/startup/main-process-pty-startup.ts +++ b/src/main/startup/main-process-pty-startup.ts @@ -171,9 +171,6 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. startAgentHookServer: async () => { const settings = state.store?.getSettings() - if (!isAgentStatusHooksEnabled(settings)) { - return - } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) // Why (#11217): the hook listener fails open on every request error, so an IDS resetting // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. @@ -182,6 +179,7 @@ export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServ track('agent_hook_transport_blocked', { count: report.count }) }) await agentHookServer.start({ + statusHooksEnabled: isAgentStatusHooksEnabled(settings), env: app.isPackaged ? 'production' : 'development', // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). userDataPath: app.getPath('userData'), diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 238998bac49a..c9ec3b30368d 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,6 +49,7 @@ import { syncMacMenuBarIcon } from './main-window-actions' import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { reportProfileStateWriteFailure } from './profile-state-write-failure' @@ -248,6 +249,7 @@ export async function initializeReadyFoundation(): Promise { syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } if ('agentStatusHooksEnabled' in updates) { + agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running // guest process and timers, and on must restart them — otherwise open WSL panes report no // status until their next spawn. diff --git a/src/relay/opencode-overlay-mirror.ts b/src/relay/opencode-overlay-mirror.ts new file mode 100644 index 000000000000..0bd5e0b0f6c8 --- /dev/null +++ b/src/relay/opencode-overlay-mirror.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readdirSync, realpathSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { mirrorEntry } from '../main/pty/overlay-mirror' + +export function mirrorOpenCodeConfig( + sourceDir: string, + overlayDir: string, + excludedPluginEntries: ReadonlySet +): void { + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + const sourcePath = join(sourceDir, entry.name) + if (entry.name === 'plugins') { + const isSymlink = entry.isSymbolicLink() + let isLinkPointingToDir = false + if (isSymlink) { + try { + isLinkPointingToDir = statSync(sourcePath).isDirectory() + } catch { + isLinkPointingToDir = false + } + } + if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { + const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath + const overlayPluginsDir = join(overlayDir, 'plugins') + mkdirSync(overlayPluginsDir, { recursive: true }) + for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { + if (excludedPluginEntries.has(pluginEntry.name)) { + continue + } + mirrorEntry( + join(resolvedSource, pluginEntry.name), + join(overlayPluginsDir, pluginEntry.name) + ) + } + continue + } + } + mirrorEntry(sourcePath, join(overlayDir, entry.name)) + } +} diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts new file mode 100644 index 000000000000..38c7934cfd64 --- /dev/null +++ b/src/relay/opencode-plugin-selection.ts @@ -0,0 +1,39 @@ +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../shared/opencode-cli-version' +import { addWslEnvKeys } from '../shared/wsl-env' + +export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + typeof value.version !== 'string' + ) { + return undefined + } + const capabilities = getOpenCodeCliCapabilities(value.version) + return capabilities.pluginApi === 'unknown' ? undefined : capabilities +} + +export function applyOpenCodePluginSelection( + env: Record, + envToDelete: string[], + capabilities: OpenCodeCliCapabilities | null | undefined, + wsl: boolean +): void { + delete env.ORCA_OPENCODE_PLUGIN_API + if (!capabilities || capabilities.pluginApi === 'unknown') { + return + } + env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi + for (let index = envToDelete.length - 1; index >= 0; index -= 1) { + if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { + envToDelete.splice(index, 1) + } + } + if (wsl) { + addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) + } +} diff --git a/src/relay/opencode-startup-prompt-install.test.ts b/src/relay/opencode-startup-prompt-install.test.ts new file mode 100644 index 000000000000..71cb373511e3 --- /dev/null +++ b/src/relay/opencode-startup-prompt-install.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PluginOverlayManager } from './plugin-overlay' +import { createInstallPluginsHandler } from './wsl-install-plugins-handler' + +let root: string +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relay-prompt-install-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) +const promptPath = (config: string) => + join(config, 'plugins', 'orca-opencode-startup-prompt', 'tui.js') +describe('execution-host startup prompt installation', () => { + it('caches prompt source independently and revokes future installs with an empty source', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + expect(manager.hasOpenCodeSource()).toBe(false) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + manager.setSources({ opencodePluginSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, config)).toBe(true) + expect(readFileSync(promptPath(config), 'utf8')).toBe('prompt source') + manager.setSources({ opencodeStartupPromptSource: '' }) + expect(manager.installOpenCodeStartupPromptPlugin({}, join(root, 'not-installed'))).toBe(false) + expect(existsSync(join(root, 'not-installed'))).toBe(false) + }) + it('materializes a prompt-only overlay without overwriting a same-named user plugin', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const config = join(root, 'custom') + mkdirSync(join(config, 'plugins', 'orca-opencode-startup-prompt'), { recursive: true }) + writeFileSync(promptPath(config), 'user collision') + writeFileSync(join(config, 'opencode.json'), '{"model":"user/model"}') + manager.setSources({ opencodeStartupPromptSource: 'prompt source' }) + const overlay = manager.materializeOpenCode('pane', config) + if (!overlay) { + throw new Error('Missing prompt overlay') + } + expect(readFileSync(promptPath(overlay), 'utf8')).toBe('prompt source') + expect(readFileSync(promptPath(config), 'utf8')).toBe('user collision') + expect(readFileSync(join(overlay, 'opencode.json'), 'utf8')).toContain('user/model') + expect(existsSync(join(overlay, 'plugins', 'orca-opencode-status.js'))).toBe(false) + }) + it('installs through WSL with both status sources disabled and preserves explicit config', () => { + const config = join(root, 'custom') + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { + HOME: root, + OPENCODE_CONFIG_DIR: config + }) + const result = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + expect(result.installed).toMatchObject({ + opencodeStartupPrompt: true, + opencode: false, + opencode2: false + }) + expect(result.overlayDirs).toEqual({}) + expect(readFileSync(promptPath(config), 'utf8')).toBe('first') + install({ opencodeStartupPromptSource: 'second' }) + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + }) + it('refreshes prompt source in both cached status overlays without rebuilding them', () => { + const manager = new PluginOverlayManager({ homeDir: root }) + const install = createInstallPluginsHandler(manager, { HOME: root }) + const first = install({ + opencodeStartupPromptSource: 'first', + opencodePluginSource: 'status v1', + opencode2PluginSource: 'status v2' + }) + const second = install({ opencodeStartupPromptSource: 'second' }) + expect(second.overlayDirs).toEqual(first.overlayDirs) + for (const config of [second.overlayDirs.opencode, second.overlayDirs.opencode2]) { + if (!config) { + throw new Error('Missing status overlay') + } + expect(readFileSync(promptPath(config), 'utf8')).toBe('second') + } + }) +}) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 6d78918c46e0..decb8bbdb3ee 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -1,3 +1,9 @@ +import { mirrorOpenCodeConfig } from './opencode-overlay-mirror' +import { + writeOpenCodeStartupPromptPlugin, + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY +} from '../shared/opencode-startup-prompt-install' +import { resolveOpenCodeConfigDirectory } from '../shared/opencode-config-directory' import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // Why: relay-side equivalent of Orca's local agent integration installers. // OpenCode still needs a config overlay, while Pi/OMP now get Orca-managed @@ -21,15 +27,12 @@ import { existsSync, mkdirSync, readFileSync, - readdirSync, - realpathSync, - statSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' -import { mirrorEntry, safeRemoveOverlay } from '../main/pty/overlay-mirror' +import { safeRemoveOverlay } from '../main/pty/overlay-mirror' import type { PiAgentKind } from '../shared/pi-agent-kind' import { installOpenCodePluginInCanonicalConfig, @@ -52,9 +55,10 @@ const PI_OVERLAY_SUBDIR_BY_KIND: Record = { const OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' const OPENCODE2_PLUGIN_FILE = 'orca-opencode2-status.js' // Orca's own entries (either major, file and TUI copy) are never mirrored from user config. -const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set( - [OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) -) +const ORCA_OPENCODE_PLUGIN_ENTRIES = new Set([ + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + ...[OPENCODE_PLUGIN_FILE, OPENCODE2_PLUGIN_FILE].flatMap((f) => [f, openCodeTuiPluginDirName(f)]) +]) const PI_EXTENSION_FILE = 'orca-agent-status.ts' const PI_AGENT_SUBDIR = 'agent' const OMP_MANAGED_STATUS_EXTENSION_DIR = 'omp-managed-status-extension' @@ -87,6 +91,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { + opencodeStartupPromptSource?: string /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ @@ -118,6 +123,7 @@ export function getRelayOpenCodePluginPath( ) } export class PluginOverlayManager { + private opencodeStartupPromptSource: string | null = null private opencodePluginSource: string | null = null private opencode2PluginSource: string | null = null private piExtensionSources: Record = { @@ -147,6 +153,9 @@ export class PluginOverlayManager { * process start. Future PTYs pick up the refreshed source when the relay * writes plugin/extension files before spawn. */ setSources(sources: PluginSources): void { + if (typeof sources.opencodeStartupPromptSource === 'string') { + this.opencodeStartupPromptSource = sources.opencodeStartupPromptSource + } if (typeof sources.opencodePluginSource === 'string') { this.opencodePluginSource = sources.opencodePluginSource } @@ -178,38 +187,6 @@ export class PluginOverlayManager { const source = this.piExtensionSources[kind] return source ?? (kind === 'omp' ? this.piExtensionSources.pi : null) } - private mirrorOpenCodeConfig(sourceDir: string, overlayDir: string): void { - for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { - const sourcePath = join(sourceDir, entry.name) - if (entry.name === 'plugins') { - const isSymlink = entry.isSymbolicLink() - let isLinkPointingToDir = false - if (isSymlink) { - try { - isLinkPointingToDir = statSync(sourcePath).isDirectory() - } catch { - isLinkPointingToDir = false - } - } - if ((!isSymlink && entry.isDirectory()) || isLinkPointingToDir) { - const resolvedSource = isLinkPointingToDir ? realpathSync(sourcePath) : sourcePath - const overlayPluginsDir = join(overlayDir, 'plugins') - mkdirSync(overlayPluginsDir, { recursive: true }) - for (const pluginEntry of readdirSync(resolvedSource, { withFileTypes: true })) { - if (ORCA_OPENCODE_PLUGIN_ENTRIES.has(pluginEntry.name)) { - continue - } - mirrorEntry( - join(resolvedSource, pluginEntry.name), - join(overlayPluginsDir, pluginEntry.name) - ) - } - continue - } - } - mirrorEntry(sourcePath, join(overlayDir, entry.name)) - } - } private writeOpenCodePlugin(overlayDir: string, pluginFileName: string, source: string): void { const pluginsDir = join(overlayDir, 'plugins') mkdirSync(pluginsDir, { recursive: true }) @@ -230,7 +207,7 @@ export class PluginOverlayManager { agent: 'opencode' | 'opencode2' = 'opencode' ): string | null { const source = agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource - if (!source || !isUsableId(id)) { + if ((!source && !this.opencodeStartupPromptSource) || !isUsableId(id)) { return null } const pluginFileName = agent === 'opencode2' ? OPENCODE2_PLUGIN_FILE : OPENCODE_PLUGIN_FILE @@ -246,9 +223,14 @@ export class PluginOverlayManager { // Why: OPENCODE_CONFIG_DIR is a single config root. Mirror the user's // remote root into the overlay before adding Orca's plugin so status // reporting does not hide their auth, models, keybinds, or plugins. - this.mirrorOpenCodeConfig(existingConfigDir, dir) + mirrorOpenCodeConfig(existingConfigDir, dir, ORCA_OPENCODE_PLUGIN_ENTRIES) + } + if (source) { + this.writeOpenCodePlugin(dir, pluginFileName, source) + } + if (this.opencodeStartupPromptSource) { + writeOpenCodeStartupPromptPlugin(dir, this.opencodeStartupPromptSource, 'overlay') } - this.writeOpenCodePlugin(dir, pluginFileName, source) return dir } catch (err) { process.stderr.write( @@ -258,6 +240,29 @@ export class PluginOverlayManager { } } + installOpenCodeStartupPromptPlugin( + environment: NodeJS.ProcessEnv | Record, + configDir?: string + ): boolean { + if (!this.opencodeStartupPromptSource) { + return false + } + try { + writeOpenCodeStartupPromptPlugin( + configDir ?? + environment.OPENCODE_CONFIG_DIR ?? + resolveOpenCodeConfigDirectory(environment, this.homeDir), + this.opencodeStartupPromptSource + ) + return true + } catch (error) { + process.stderr.write( + `[plugin-overlay] failed to install OpenCode startup prompt: ${error instanceof Error ? error.message : String(error)}\n` + ) + return false + } + } + hasOpenCode2Source(): boolean { return this.hasOpenCodeSource('opencode2') } diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts new file mode 100644 index 000000000000..1c73f8be1fe6 --- /dev/null +++ b/src/relay/pty-handler-opencode-revive.test.ts @@ -0,0 +1,142 @@ +import './mock-descendant-sweep' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { applyOpenCodePluginSelection } from './opencode-plugin-selection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createMockDispatcher, + createTestPtyHandler, + endPtyHandlerTest, + testPtyId, + type MockDispatcher +} from './pty-handler-test-harness' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + probe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: mocks.probe +})) + +let dispatcher: MockDispatcher +let handler: PtyHandler +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) + mocks.probe.mockReset() +}) + +afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) + +function spawnedEnvironment(): Record { + const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] + if ( + !options || + typeof options !== 'object' || + !('env' in options) || + !options.env || + typeof options.env !== 'object' + ) { + throw new Error('Expected a PTY spawn environment') + } + return Object.fromEntries(Object.entries(options.env)) +} + +async function serialize(): Promise { + const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) + if (typeof state !== 'string') { + throw new Error('Expected serialized PTY state') + } + return state +} + +async function restartHandler(): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + mocks.mockPtySpawn.mockClear() +} + +it.each(['1.18.32', '2.0.16'])( + 'preserves host-selected plugin exports through two relay revives on %s', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + mocks.probe.mockResolvedValue(capabilities) + await dispatcher.callRequest('pty.spawn', { + cwd: tmpdir(), + command: 'opencode --standalone', + launchAgent: 'opencode', + env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, + envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const state = await serialize() + expect(JSON.parse(state)).toMatchObject([ + { openCodeCapabilities: capabilities, envToDelete: [] } + ]) + + await restartHandler() + await dispatcher.callRequest('pty.revive', { state }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const second = await serialize() + await restartHandler() + await dispatcher.callRequest('pty.revive', { state: second }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + expect(mocks.probe).toHaveBeenCalledOnce() + } +) + +it.each([ + undefined, + { version: 'garbage', pluginApi: 'v1' }, + { version: '3.0.0', pluginApi: 'v1' }, + { version: 2, pluginApi: 'v1' } +])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { + handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) + await dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { + id: testPtyId(1), + pid: process.pid, + cols: 80, + rows: 24, + cwd: tmpdir(), + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) + } + ]) + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() + expect(mocks.probe).not.toHaveBeenCalled() +}) + +it('carries a selected API into WSL without dropping other forwarded keys', () => { + const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } + const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] + applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) + expect(env).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' + }) + expect(envToDelete).toEqual(['OTHER_ENV']) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b80b7ffdbd14..42f2a7b8fcab 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -4,6 +4,13 @@ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchroni import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { + applyOpenCodePluginSelection, + restoreOpenCodeCapabilities +} from './opencode-plugin-selection' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -26,6 +33,13 @@ import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' import { RetiredPaneSurfaceRegistry } from './retired-pane-surfaces' import { applyScrubSafeAgentEnvAliases } from '../shared/agent-hook-scrub-safe-env' import { addWslEnvKeys } from '../shared/wsl-env' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from '../shared/opencode-startup-prompt' import { ORCA_IMAGE_PROTOCOL_ENV, ORCA_IMAGE_PROTOCOL_VALUE @@ -217,6 +231,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -276,6 +291,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -447,6 +463,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -1929,6 +1946,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1957,6 +1977,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -2003,6 +2024,32 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + // Relay input streams lack driving-input provenance, so native intent is unavailable. + for (const key of [ + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_NONCE_ENV, + OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV + ]) { + delete spawnEnv[key] + } + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2103,6 +2150,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2181,7 +2229,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } @@ -3070,6 +3119,9 @@ export class PtyHandler { worktreeId: managed.worktreeId, ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, + ...(managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), // Why serialized: revive re-spawns the shell, and without these a WSL @@ -3164,6 +3216,8 @@ export class PtyHandler { { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) + const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) + applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) if ( historyIsolationEnabled && entry.worktreeId && @@ -3239,6 +3293,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, // Why re-stored: a revived pane can be serialized again, and losing the diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 67f29c1cba29..4bb47dd66e26 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -185,17 +185,20 @@ export class RelayAgentHookRuntime { })) registerManagedHookInstaller(this.dispatcher) this.dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) this.pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -212,8 +215,12 @@ export class RelayAgentHookRuntime { installOpenCodePluginInCanonicalConfig(source, agent, process.env, homedir(), true) } } + const startupPromptInstalled = this.pluginOverlay.installOpenCodeStartupPromptPlugin( + process.env + ) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: this.pluginOverlay.hasOpenCodeSource(), opencode2: this.pluginOverlay.hasOpenCode2Source(), pi: this.pluginOverlay.hasPiSource('pi'), diff --git a/src/relay/wsl-install-plugins-handler.ts b/src/relay/wsl-install-plugins-handler.ts index b602d353ea83..e66ff7d20a5e 100644 --- a/src/relay/wsl-install-plugins-handler.ts +++ b/src/relay/wsl-install-plugins-handler.ts @@ -16,6 +16,7 @@ import { export type InstallPluginsResult = { installed: { + opencodeStartupPrompt?: boolean opencode: boolean opencode2?: boolean pi: boolean @@ -43,18 +44,21 @@ export function createInstallPluginsHandler( let materialized2: { source: string; sourceDir: string | undefined; dir: string } | null = null return (params) => { + const startupPrompt = params.opencodeStartupPromptSource const opencode = params.opencodePluginSource const opencode2 = params.opencode2PluginSource const pi = params.piExtensionSource const omp = params.ompExtensionSource const primeAgent = params.primeAgentExtensionSource // Why: bound per-source bytes so a buggy/hostile host can't OOM the guest relay. + assertPluginSourceUnderByteCap('opencodeStartupPromptSource', startupPrompt) assertPluginSourceUnderByteCap('opencodePluginSource', opencode) assertPluginSourceUnderByteCap('opencode2PluginSource', opencode2) assertPluginSourceUnderByteCap('piExtensionSource', pi) assertPluginSourceUnderByteCap('ompExtensionSource', omp) assertPluginSourceUnderByteCap('primeAgentExtensionSource', primeAgent) pluginOverlay.setSources({ + opencodeStartupPromptSource: typeof startupPrompt === 'string' ? startupPrompt : undefined, opencodePluginSource: typeof opencode === 'string' ? opencode : undefined, opencode2PluginSource: typeof opencode2 === 'string' ? opencode2 : undefined, piExtensionSource: typeof pi === 'string' ? pi : undefined, @@ -135,8 +139,28 @@ export function createInstallPluginsHandler( } } } + const promptConfigDirs = [opencodeDir, opencode2Dir].filter( + (dir): dir is string => typeof dir === 'string' + ) + if (promptConfigDirs.length === 0) { + promptConfigDirs.push( + resolveOpenCodeSourceConfigDir( + Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => + typeof value === 'string' ? [[key, value]] : [] + ) + ), + env.SHELL + ) ?? resolveOpenCodeConfigDirectory(env, env.HOME) + ) + } + const promptInstallResults = promptConfigDirs.map((dir) => + pluginOverlay.installOpenCodeStartupPromptPlugin(env, dir) + ) + const startupPromptInstalled = promptInstallResults.every(Boolean) return { installed: { + opencodeStartupPrompt: startupPromptInstalled, opencode: pluginOverlay.hasOpenCodeSource(), opencode2: pluginOverlay.hasOpenCode2Source(), pi: pluginOverlay.hasPiSource('pi'), diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index a37111a2e22c..feaa40fa260a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' @@ -87,6 +88,27 @@ describe('createDraftPasteReadyScanner', () => { }) }) + it('never joins a show-cursor across a chunk seam from bytes it already scanned', () => { + // Why: the stream holds only a hide-cursor; re-reading carried chars used to assemble a show. + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(DECSET_BRACKETED_PASTE).ready).toBe(false) + expect(scanner.observe('Search \x1b[?25').ready).toBe(false) + expect(scanner.observe('l more text').ready).toBe(false) + }) + + it('ignores a show-cursor after the shell turns bracketed paste back off to run a command', () => { + // zsh's prompt enables bracketed paste and disables it on accept-line, before the launcher + // runs; the launcher's cursor toggle stands in for any spinner (synthetic). + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}% opencode`).ready).toBe(false) + expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\n`).ready).toBe(false) + expect(scanner.observe(`${HIDE_CURSOR}resolving${SHOW_CURSOR}`).ready).toBe(false) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + it('ignores show-cursor that appears before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') // A pre-handshake cursor toggle must not trip readiness. @@ -272,17 +294,16 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('Windows ConPTY, which never forwards DECSET 2004', () => { - // Why: measured on a remote Windows host (terminal-agent-paste-bracketing.test.ts) — the - // agent's `\x1b[?2004h` is consumed by conhost and never reaches the client stream. Every - // signal below is anchored on it, so on Windows readiness cannot resolve and delivery always - // falls through to the caller's hard timeout and its blind process-ownership paste (#22479). - const WINDOWS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` + describe('a stream that never carries DECSET 2004', () => { + // Why: every signal below is anchored on `\x1b[?2004h`, so without it readiness cannot resolve + // and delivery falls through to the caller's hard timeout. A transport that loses the sequence + // lands here; terminal-agent-paste-bracketing.ts names remote replay and ConPTY as possible. + const ANCHORLESS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` it('never reports opencode ready from show-cursor frames alone', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') for (let frame = 0; frame < 5; frame += 1) { - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) @@ -291,7 +312,7 @@ describe('createDraftPasteReadyScanner', () => { it('never arms the default quiet window either', () => { const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste') - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f7a9cb7c0c52..c079c724c601 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,6 +4,7 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, @@ -48,7 +49,10 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const scanRevocableAnchorSegments = ( + window: string, + carriedLength: number, + anchor: string, + end: string + ): boolean => { let cursor = 0 while (cursor < window.length) { if (!sawMarkerAnchor) { @@ -191,7 +201,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) continue } const leaveIndex = window.indexOf(end, cursor) - const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex) + // Why: carried chars only rejoin a split anchor or leave; postAnchorRecent already holds + // them, so re-reading them could join a marker across the duplicated seam. + const segmentStart = Math.max(cursor, carriedLength) + const segment = + leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex) if ((postAnchorRecent + segment).includes(signalMarker ?? '')) { return true } @@ -251,9 +265,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned // output is never re-walked into a second enter/leave transition. + const carriedLength = anchorCarry.length const window = anchorCarry + data anchorCarry = window.slice(-ANCHOR_CARRY_CHARS) - if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) { + if (scanRevocableAnchorSegments(window, carriedLength, markerAnchor, markerAnchorEnd)) { return { ready: true, armQuietTimer: false } } } else if (!sawMarkerAnchor) { diff --git a/src/shared/flag-prompt-startup.ts b/src/shared/flag-prompt-startup.ts new file mode 100644 index 000000000000..50e1be306faf --- /dev/null +++ b/src/shared/flag-prompt-startup.ts @@ -0,0 +1,50 @@ +import type { AgentStartupPlan } from './tui-agent-startup' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import type { TuiAgent } from './tui-agent' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import { findOpenCodeRunCommand } from './opencode-headless-command' +import { openCodeStartupPromptEnv } from './opencode-startup-prompt' + +export function appliedSessionOptionProps(values: Record) { + return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} +} + +export function buildFlagPromptStartupPlan(args: { + agent: TuiAgent + launchCommand: string + quotedPrompt: string + prompt: string + shell: AgentStartupShell + launchConfig: SleepingAgentLaunchConfig + sessionOptions: Record + agentEnv: Record | null | undefined +}): AgentStartupPlan { + const parsed = tokenizeStartupCommand(args.launchCommand, args.shell) + const openCodeRun = + (args.agent === 'opencode' || args.agent === 'opencode2') && parsed.ok + ? findOpenCodeRunCommand(parsed.tokens, args.shell) + : null + // OpenCode run takes a positional message; --prompt belongs to its TUI. + const promptSuffix = openCodeRun + ? openCodeRun.messageSeparatorIndex !== null + ? ` ${args.quotedPrompt}` + : ` -- ${args.quotedPrompt}` + : ` --prompt ${args.quotedPrompt}` + return { + agent: args.agent, + launchCommand: `${args.launchCommand}${promptSuffix}`, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig: args.launchConfig, + ...appliedSessionOptionProps(args.sessionOptions), + ...openCodeStartupPromptEnv( + args.agent, + args.launchCommand, + args.shell, + args.prompt, + args.agentEnv + ) + } +} diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 000000000000..cc9c5987f2e9 --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 000000000000..2aa71b2c30f0 --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' + } +} diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 408895a1dbb9..cc240be7f9c2 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' import { tokenizeCommandLine } from './agent-command-line-entrypoint' import { isOpenCodeRunCommand } from './opencode-headless-command' @@ -24,3 +25,32 @@ describe('isOpenCodeRunCommand', () => { expect(matches('opencode --log-level run')).toBe(false) }) }) + +describe('wrapped OpenCode run command position', () => { + it.each([ + 'CUSTOM_CONFIG=private opencode --log-level debug run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'CUSTOM_CONFIG=private /usr/bin/env -- EXTRA_CONFIG=kept opencode run --standalone' + ])('recognizes only the executable behind supported POSIX prefixes: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it('recognizes a PowerShell call operator before a quoted executable', () => { + const parsed = tokenizeStartupCommand( + '& "C:\\Program Files\\opencode\\opencode.exe" run --standalone', + 'powershell' + ) + expect(parsed.ok).toBe(true) + if (!parsed.ok) throw new Error(parsed.error) + expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) + expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) + }) + + it('does not find executable names inside other commands or prompt arguments', () => { + expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) + expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) + expect(matches('CUSTOM_CONFIG=private opencode --prompt "opencode run"')).toBe(false) + expect(matches('env -- opencode serve --title run')).toBe(false) + expect(matches('opencode attach http://host/run')).toBe(false) + }) +}) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 5328f7931fce..858fb9651256 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -1,16 +1,82 @@ -// Why: `opencode run` answers one prompt and exits, so its process lifetime is its turn. -// Not in agent-headless-command's table: that would also drop OpenCode 1 `run`'s identity, -// whose in-process plugin reports it. Only `--log-level` takes a separate value before the -// subcommand; any other valued option makes the value the first positional, which fails safe. -export function isOpenCodeRunCommand(tokens: readonly string[]): boolean { - for (let index = 1; index < tokens.length; index += 1) { +import { extractLeadingEnvAssignments } from './command-environment' +import { getCommandTokenPathBasename } from './command-token-scanner' +import type { AgentStartupShell } from './tui-agent-startup-shell' + +const RUN_VALUE_FLAGS = new Set([ + '--log-level', + '--completions', + '--server', + '--session', + '-s', + '--model', + '-m', + '--agent', + '--format', + '--file', + '-f', + '--title' +]) + +function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { + if (shell === 'powershell' && tokens[0] === '&') { + return 1 + } + if (shell !== 'posix') { + return 0 + } + let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length + if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { + index += 1 + if (tokens[index] === '--') { + index += 1 + } + index = tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length + } + return index +} + +export function findOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): { runIndex: number; messageSeparatorIndex: number | null } | null { + const commandPosition = openCodeCommandPosition(tokens, shell) + if (commandPosition > 0) { + const binary = getCommandTokenPathBasename(tokens[commandPosition] ?? '') + .toLowerCase() + .replace(/\.(?:exe|cmd)$/, '') + if (binary !== 'opencode' && binary !== 'opencode2') { + return null + } + } + for (let index = commandPosition + 1; index < tokens.length; index += 1) { const token = tokens[index] if (!token.startsWith('-')) { - return token === 'run' + if (token !== 'run') { + return null + } + for (let argumentIndex = index + 1; argumentIndex < tokens.length; argumentIndex += 1) { + const argument = tokens[argumentIndex] + if (argument === '--') { + return { runIndex: index, messageSeparatorIndex: argumentIndex } + } + if (RUN_VALUE_FLAGS.has(argument)) { + argumentIndex += 1 + } + } + return { runIndex: index, messageSeparatorIndex: null } } + // Other valued global options fail closed at their first positional value. if (token === '--log-level') { index += 1 } } - return false + return null +} + +// OpenCode run's process lifetime is its turn; v1 still reports through its plugin. +export function isOpenCodeRunCommand( + tokens: readonly string[], + shell: AgentStartupShell = 'posix' +): boolean { + return findOpenCodeRunCommand(tokens, shell) !== null } diff --git a/src/shared/opencode-startup-prompt-install.ts b/src/shared/opencode-startup-prompt-install.ts new file mode 100644 index 000000000000..6427a9e98864 --- /dev/null +++ b/src/shared/opencode-startup-prompt-install.ts @@ -0,0 +1,17 @@ +import { join } from 'node:path' +import { writeOpenCodeTuiPluginDirectory } from './opencode-tui-plugin-install' + +export const OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY = 'orca-opencode-startup-prompt' + +export function writeOpenCodeStartupPromptPlugin( + configDir: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + writeOpenCodeTuiPluginDirectory( + join(configDir, 'plugins'), + OPENCODE_STARTUP_PROMPT_PLUGIN_DIRECTORY, + source, + ownership + ) +} diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts new file mode 100644 index 000000000000..e82b46f8e771 --- /dev/null +++ b/src/shared/opencode-startup-prompt.test.ts @@ -0,0 +1,159 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan, buildAgentDraftLaunchPlan } from './tui-agent-startup' +import { + OPENCODE_STARTUP_PROMPT_SHA256_ENV, + OPENCODE_STARTUP_PROMPT_BODY_ENV, + OPENCODE_STARTUP_PROMPT_SHELL_ENV +} from './opencode-startup-prompt' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +describe('native OpenCode startup submission intent', () => { + it('binds the exact trimmed native prompt to host-selectable transport', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: ' task\nwith unicode é ', + cmdOverrides: {}, + platform: 'linux' + }) + expect(plan?.env).toEqual({ + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256') + .update('task\nwith unicode é') + .digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task\nwith unicode é', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + }) + expect(plan?.launchCommand).toContain('--prompt') + }) + + it('preserves explicit run commands without a submission intent', () => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: 'task', + cmdOverrides: { opencode: 'opencode --log-level debug run' }, + platform: 'linux' + }) + expect(plan?.env).toBeUndefined() + expect(plan?.launchCommand).toContain("run -- 'task'") + }) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'keeps run and its flags before a positional message in %s', + (shell) => { + for (const agent of ['opencode', 'opencode2'] as const) { + const prompt = '--literal task with unicode é' + const plan = buildAgentStartupPlan({ + agent, + prompt, + cmdOverrides: { [agent]: 'opencode --log-level debug run --standalone' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan).not.toBeNull() + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual([ + 'opencode', + '--log-level', + 'debug', + 'run', + '--standalone', + '--', + prompt + ]) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'reuses an existing run message separator in %s', + (shell) => { + const plan = buildAgentStartupPlan({ + agent: 'opencode', + prompt: '--literal task', + cmdOverrides: { opencode: 'opencode run --standalone --' }, + platform: shell === 'posix' ? 'linux' : 'win32', + shell + }) + const parsed = tokenizeStartupCommand(plan?.launchCommand ?? '', shell) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + expect(parsed.tokens).toEqual(['opencode', 'run', '--standalone', '--', '--literal task']) + expect(plan?.env).toBeUndefined() + } + ) + + it('never gives an editable draft or empty launch an automatic submission intent', () => { + const args = { agent: 'opencode' as const, cmdOverrides: {}, platform: 'linux' as const } + expect( + buildAgentDraftLaunchPlan({ ...args, draft: 'draft' })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + expect( + buildAgentStartupPlan({ ...args, prompt: '', allowEmptyPromptLaunch: true })?.env?.[ + OPENCODE_STARTUP_PROMPT_SHA256_ENV + ] + ).toBeUndefined() + }) +}) + +describe('wrapped OpenCode run startup', () => { + it.each(['opencode', 'opencode2'] as const)( + 'keeps %s run flags and positional task behind POSIX prefixes', + (agent) => { + for (const command of [ + 'CUSTOM_CONFIG=private opencode run --standalone', + 'env CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone', + 'env -- CUSTOM_CONFIG=private opencode run --standalone --', + 'env CUSTOM_CONFIG=private opencode run --title "--"' + ]) { + const plan = buildAgentStartupPlan({ + agent, + prompt: '--literal task', + cmdOverrides: { [agent]: command }, + platform: 'linux', + shell: 'posix', + isRemote: true, + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe( + command.endsWith(' --') ? `${command} '--literal task'` : `${command} -- '--literal task'` + ) + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) + + it.each(['opencode', 'opencode2'] as const)( + 'preserves %s PowerShell call syntax and its run separator', + (agent) => { + for (const separator of ['', ' --']) { + const command = + '& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone' + + separator + const plan = buildAgentStartupPlan({ + agent, + prompt: "--task's é", + cmdOverrides: { [agent]: command }, + platform: 'win32', + shell: 'powershell', + agentEnv: { CUSTOM_CONFIG: 'kept' } + }) + expect(plan?.launchCommand).toBe(command + (separator ? ' ' : ' -- ') + "'--task''s é'") + expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) + expect(plan?.followupPrompt).toBeNull() + } + } + ) +}) diff --git a/src/shared/opencode-startup-prompt.ts b/src/shared/opencode-startup-prompt.ts new file mode 100644 index 000000000000..e3b1c462ffa4 --- /dev/null +++ b/src/shared/opencode-startup-prompt.ts @@ -0,0 +1,39 @@ +import { isOpenCodeRunCommand } from './opencode-headless-command' +import { sha256 } from './sha256' +import { tokenizeStartupCommand, type AgentStartupShell } from './tui-agent-startup-shell' +import type { TuiAgent } from './tui-agent' + +export const OPENCODE_STARTUP_PROMPT_SHA256_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256' +export const OPENCODE_STARTUP_PROMPT_NONCE_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE' +export const OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT' +export const OPENCODE_STARTUP_PROMPT_CLAIM_PATH = '/opencode/startup-prompt/claim' +export const OPENCODE_STARTUP_PROMPT_BODY_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_BODY' +export const OPENCODE_STARTUP_PROMPT_SHELL_ENV = 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL' + +export function openCodeStartupPromptEnv( + agent: TuiAgent, + command: string, + shell: AgentStartupShell, + prompt: string, + env: Record | null | undefined +): { env?: Record } { + const parsed = tokenizeStartupCommand(command, shell) + if ( + (agent !== 'opencode' && agent !== 'opencode2') || + !parsed.ok || + isOpenCodeRunCommand(parsed.tokens, shell) + ) { + return env ? { env: { ...env } } : {} + } + const digest = Array.from(sha256(new TextEncoder().encode(prompt)), (byte) => + byte.toString(16).padStart(2, '0') + ).join('') + return { + env: { + ...env, + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: digest, + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: prompt, + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: shell + } + } +} diff --git a/src/shared/opencode-tui-plugin-install.ts b/src/shared/opencode-tui-plugin-install.ts index cad825212a8c..055aa3b7012e 100644 --- a/src/shared/opencode-tui-plugin-install.ts +++ b/src/shared/opencode-tui-plugin-install.ts @@ -29,7 +29,21 @@ export function writeOpenCodeTuiPlugin( source: string, ownership: 'canonical' | 'overlay' = 'canonical' ): void { - const dir = join(pluginsDir, openCodeTuiPluginDirName(pluginFileName)) + writeOpenCodeTuiPluginDirectory( + pluginsDir, + openCodeTuiPluginDirName(pluginFileName), + source, + ownership + ) +} + +export function writeOpenCodeTuiPluginDirectory( + pluginsDir: string, + directoryName: string, + source: string, + ownership: 'canonical' | 'overlay' = 'canonical' +): void { + const dir = join(pluginsDir, directoryName) const entry = join(dir, 'tui.js') const isCurrent = ownership === 'canonical' ? isInstalledOpenCodePluginCurrent : isOverlayOpenCodePluginCurrent diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..6b3fcb464a48 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,6 +43,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Captured boots proving the composer marker used for fresh worker dispatch. */ + composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a7be259df2cd..e2a96d978cec 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch' + ] }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -108,7 +113,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] }, 'mimo-code': { detectCmd: 'mimo', @@ -311,7 +317,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', // Why: ZCode repaints an animated ASCII banner indefinitely, so the default quiet-render // window never settles; its composer box corner is the real "input is live" signal. - draftPasteReadySignal: 'zcode-composer-prompt' + draftPasteReadySignal: 'zcode-composer-prompt', + composerReadyCaptures: ['zcode-composer-ready'] }, devin: { detectCmd: 'devin', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index c381454e9c41..8c56c1d9d95f 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -17,6 +17,7 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import { appliedSessionOptionProps, buildFlagPromptStartupPlan } from './flag-prompt-startup' export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' @@ -35,10 +36,6 @@ export type AgentStartupPlan = { sessionOptions?: Record } -function appliedSessionOptionProps(values: Record) { - return Object.keys(values).length > 0 ? { sessionOptions: { ...values } } : {} -} - export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -116,15 +113,16 @@ export function buildAgentStartupPlan(args: { } if (config.promptInjectionMode === 'flag-prompt') { - return { + return buildFlagPromptStartupPlan({ agent, - launchCommand: `${launchCommand} --prompt ${quotedPrompt}`, - expectedProcess: config.expectedProcess, - followupPrompt: null, + launchCommand, + quotedPrompt, + prompt: trimmedPrompt, + shell, launchConfig, - ...appliedSessionOptionProps(baseCommand.appliedSessionOptions), - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } + sessionOptions: baseCommand.appliedSessionOptions, + agentEnv: args.agentEnv + }) } if (config.promptInjectionMode === 'hermes-query') { From 350e6b5572d490759f5379837f8531b6f947873e Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 01:37:11 -0700 Subject: [PATCH 67/93] Prepare private complete 111-path launch validation on current main Private validation only. Preserve main credential additions and original launch ownership. Held model and provider topics remain excluded. --- .../server-startup-prompt-control.test.ts | 2 +- src/main/ipc/pty/runtime/spawn-options.ts | 16 ++++------------ .../launched-agent-composer-readiness.test.ts | 1 + src/relay/plugin-overlay.ts | 7 +------ src/shared/opencode-headless-command.test.ts | 4 +++- src/shared/opencode-startup-prompt.test.ts | 6 ++---- 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/main/agent-hooks/server-startup-prompt-control.test.ts b/src/main/agent-hooks/server-startup-prompt-control.test.ts index 1704b518c7ad..94d0b347648f 100644 --- a/src/main/agent-hooks/server-startup-prompt-control.test.ts +++ b/src/main/agent-hooks/server-startup-prompt-control.test.ts @@ -14,7 +14,7 @@ describe('startup prompt control with status hooks disabled', () => { super() this._setOpenCodeBinderDepsForTests({ dbPath: () => join(dir, 'no-user-db'), - listSessions: () => [], + listSessions: async () => [], listPanes: () => [], sweep: async () => [] }) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 2c52daa87a41..26c3856b45ee 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -86,20 +86,12 @@ export async function buildRuntimePtySpawnOptions( ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) - ) - if (ctx.skipCodexHomeEnv) { - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - ctx.spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (ctx.stripInheritedOrcaCodexHome) { + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the daemon owns a persistent inherited environment that may // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) if (ctx.codexResumeHomeSelected) { ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index 7f8e7a18fa15..914ce7ee1160 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -43,6 +43,7 @@ const EXPECTED_LANES: Record = { copilot: 'tui-idle', muse: 'tui-idle', qoder: 'tui-idle', + 'qoder-cn': 'tui-idle', codebuddy: 'tui-idle', autohand: 'tui-idle', ante: 'tui-idle', diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index decb8bbdb3ee..08c9f772bed7 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -23,12 +23,7 @@ import { materializeOmpFreshConfig } from '../shared/omp-fresh-config' // implementation rooted at $HOME/.orca-relay/ for OpenCode and at the remote // Pi/OMP homes for those agents. import { createHash } from 'node:crypto' -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync -} from 'node:fs' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { writeOverlayOpenCodePluginAtomically } from '../shared/opencode-plugin-atomic-write' import { homedir } from 'node:os' import { join } from 'node:path' diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index cc240be7f9c2..542109e9d7db 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -41,7 +41,9 @@ describe('wrapped OpenCode run command position', () => { 'powershell' ) expect(parsed.ok).toBe(true) - if (!parsed.ok) throw new Error(parsed.error) + if (!parsed.ok) { + throw new Error(parsed.error) + } expect(isOpenCodeRunCommand(parsed.tokens, 'powershell')).toBe(true) expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) }) diff --git a/src/shared/opencode-startup-prompt.test.ts b/src/shared/opencode-startup-prompt.test.ts index e82b46f8e771..286f7e3ad2f5 100644 --- a/src/shared/opencode-startup-prompt.test.ts +++ b/src/shared/opencode-startup-prompt.test.ts @@ -139,9 +139,7 @@ describe('wrapped OpenCode run startup', () => { 'preserves %s PowerShell call syntax and its run separator', (agent) => { for (const separator of ['', ' --']) { - const command = - '& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone' + - separator + const command = `& "C:\\Program Files\\opencode\\opencode.exe" --log-level debug run --standalone${separator}` const plan = buildAgentStartupPlan({ agent, prompt: "--task's é", @@ -150,7 +148,7 @@ describe('wrapped OpenCode run startup', () => { shell: 'powershell', agentEnv: { CUSTOM_CONFIG: 'kept' } }) - expect(plan?.launchCommand).toBe(command + (separator ? ' ' : ' -- ') + "'--task''s é'") + expect(plan?.launchCommand).toBe(`${command}${separator ? ' ' : ' -- '}'--task''s é'`) expect(plan?.env).toEqual({ CUSTOM_CONFIG: 'kept' }) expect(plan?.followupPrompt).toBeNull() } From 9b5fe809ad8876355e2538d4d45bfe2ea6066ab3 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 04:53:27 -0700 Subject: [PATCH 68/93] Recognize env options before positional OpenCode run messages --- src/shared/opencode-headless-command.test.ts | 32 +++++++++++++++++++ src/shared/opencode-headless-command.ts | 33 +++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/shared/opencode-headless-command.test.ts b/src/shared/opencode-headless-command.test.ts index 542109e9d7db..09d243a4bcf7 100644 --- a/src/shared/opencode-headless-command.test.ts +++ b/src/shared/opencode-headless-command.test.ts @@ -48,6 +48,38 @@ describe('wrapped OpenCode run command position', () => { expect(isOpenCodeRunCommand(parsed.tokens, 'cmd')).toBe(false) }) + it.each([ + 'env -u FOO opencode run', + 'env -uFOO opencode run', + 'env --unset FOO opencode run', + 'env --unset=FOO opencode run', + 'env -i PRIVATE_CONFIG=kept opencode run', + 'env --ignore-environment PRIVATE_CONFIG=kept opencode run', + 'env - PRIVATE_CONFIG=kept opencode run', + 'env -C /workspace opencode run', + 'env -C/workspace opencode run', + 'env --chdir /workspace opencode run', + 'env --chdir=/workspace opencode run', + 'env -P /private/bin opencode run', + 'env -P/private/bin opencode run', + 'CONFIG=kept /usr/bin/env -i -u FOO -C /workspace -- OTHER=kept opencode run' + ])('recognizes an executable after env options: %s', (command) => { + expect(matches(command)).toBe(true) + }) + + it.each([ + 'env -u', + 'env -C', + 'env -u opencode run', + 'env -C opencode run', + 'env -u FOO echo opencode run', + 'env --unset=FOO echo run', + 'env -S "opencode run"', + 'env --unknown opencode run' + ])('does not mistake env option arguments for the executable: %s', (command) => { + expect(matches(command)).toBe(false) + }) + it('does not find executable names inside other commands or prompt arguments', () => { expect(matches('env CUSTOM_CONFIG=private echo opencode run')).toBe(false) expect(matches('env CUSTOM_CONFIG=private echo run')).toBe(false) diff --git a/src/shared/opencode-headless-command.ts b/src/shared/opencode-headless-command.ts index 858fb9651256..766d70642fa4 100644 --- a/src/shared/opencode-headless-command.ts +++ b/src/shared/opencode-headless-command.ts @@ -17,6 +17,33 @@ const RUN_VALUE_FLAGS = new Set([ '--title' ]) +const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-P']) +const ENV_EMPTY_FLAGS = new Set(['-i', '--ignore-environment', '-']) + +function envCommandPosition(tokens: readonly string[], offset: number): number { + let index = offset + while (index < tokens.length) { + const token = tokens[index] + if (token === '--') { + index += 1 + break + } + if (ENV_EMPTY_FLAGS.has(token)) { + index += 1 + } else if (ENV_VALUE_FLAGS.has(token)) { + index += 2 + } else if (/^(?:-[uCP].+|--(?:unset|chdir)=)/.test(token)) { + index += 1 + } else if (token.startsWith('-')) { + // Split-string options need another parse before their executable is known. + return tokens.length + } else { + break + } + } + return tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length +} + function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupShell): number { if (shell === 'powershell' && tokens[0] === '&') { return 1 @@ -26,11 +53,7 @@ function openCodeCommandPosition(tokens: readonly string[], shell: AgentStartupS } let index = tokens.length - extractLeadingEnvAssignments(tokens.slice()).rest.length if (getCommandTokenPathBasename(tokens[index] ?? '') === 'env') { - index += 1 - if (tokens[index] === '--') { - index += 1 - } - index = tokens.length - extractLeadingEnvAssignments(tokens.slice(index)).rest.length + index = envCommandPosition(tokens, index + 1) } return index } From 40d90386dd8b784f5f238416b98d0b7e61a8005c Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:16:45 -0700 Subject: [PATCH 69/93] STRICT launch CI contract correction --- src/main/runtime/zcode-readiness-transcript.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index bd9909f89de7..558aa3f306e5 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -28,9 +28,13 @@ describe('ZCode readiness from captured terminal bytes', () => { launchAgent: 'zcode', data: '\x1b[?1049h╭' }) - await expect( - runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) - ).resolves.toBeUndefined() + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) }) it('never emits an OSC title, so no title lane can settle its wait', () => { From 37f0be5e6e983bdaa624f7d4a2eab52847637892 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:16:45 -0700 Subject: [PATCH 70/93] CAPS launch CI contract correction --- src/main/providers/provider-dispatch.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fde..12b259f5b9f0 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -175,6 +175,7 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', From a6d32ed1cc762877222795c210b5e5126e62ea16 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:16:46 -0700 Subject: [PATCH 71/93] INTENT launch CI contract correction --- src/main/global-fetch-call-site-audit.test.ts | 2 + .../pty-controller-process-inventory.test.ts | 14 +---- ...de-startup-prompt-runtime-identity.test.ts | 53 +++++++++++++++++++ .../opencode-startup-prompt-source.test.ts | 42 +++++++++++++++ .../opencode-startup-prompt-source.ts | 12 +++-- src/main/providers/provider-dispatch.test.ts | 20 +++---- ...-authoritative-terminal-wait-permission.ts | 19 +++++++ src/main/runtime/orca-runtime-runtime-id.ts | 19 ------- .../headless-pty-hydration-ordering.test.ts | 18 ++++++- 9 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 907092515f54..664bbaf1aa32 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -20,6 +20,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/bitbucket/client.ts', 1], ['main/bitbucket/user-request.ts', 1], ['main/gitea/client.ts', 1], + // Generated OpenCode claim source consumes JSON or cancels its body in finally. + ['main/opencode/opencode-startup-prompt-source.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index 004735c61cbc..50b67963ab90 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from './pty-ipc-mock-registry' import { afterEach, describe, expect, it, vi } from 'vitest' const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ @@ -46,18 +47,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } diff --git a/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts new file mode 100644 index 000000000000..0fb975652d89 --- /dev/null +++ b/src/main/opencode/opencode-startup-prompt-runtime-identity.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createTranscriptPane, + TRANSCRIPT_PANE_PTY_ID +} from '../runtime/agent-transcript-pane-test-harness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +async function fixture(launchAgent?: 'opencode' | 'opencode2' | 'zcode') { + const { runtime } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '', + ...(launchAgent ? { launchAgent } : {}) + }) + runtime.terminalRunFacts.recordSpawnCommit({ id: TRANSCRIPT_PANE_PTY_ID, incarnationId: 'inc-1' }) + const read = ( + ptyId = TRANSCRIPT_PANE_PTY_ID, + incarnation = 'inc-1', + token = 'transcript-launch' + ) => runtime.readOpenCodeStartupPromptOwner(ptyId, incarnation, token) + return { runtime, read } +} + +describe('runtime-owned startup prompt identity', () => { + it('keeps launch admission pending before runtime identity is assigned', async () => { + const f = await fixture() + expect(f.read()).toBe('pending') + expect(f.read('missing')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + }) + + it.each(['opencode', 'opencode2'] as const)( + 'reads only the admitted %s launch', + async (agent) => { + const f = await fixture(agent) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: null }) + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'inc-1', 'another-launch')).toBeNull() + expect(f.read(TRANSCRIPT_PANE_PTY_ID, 'previous-incarnation')).toBeNull() + f.runtime.terminalRunFacts.recordInput(TRANSCRIPT_PANE_PTY_ID, 'driving', 'x', 123) + expect(f.read()).toEqual({ freshSpawn: true, firstUserInputAt: 123 }) + } + ) + + it('denies another agent even with the exact incarnation and launch token', async () => { + expect((await fixture('zcode')).read()).toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 279164adc087..74720af0a4e7 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -12,6 +12,7 @@ import { OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV } from '../../shared/opencode-startup-prompt' import { getOpenCodeStartupPromptSource } from './opencode-startup-prompt-source' +import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixtures' type PluginModule = { default: { setup: (ctx: unknown) => Promise<() => Promise> } } const prompt = 'exact startup brief\nwith unicode é' @@ -84,6 +85,47 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { + const cancelled = vi.fn() + const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) + if (reason === 'json-rejected') { + vi.spyOn(response, 'json').mockRejectedValue(new Error('decoder rejected')) + } + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(cancelled).toHaveBeenCalledTimes(1) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + + it('consumes an allowed claim body before dispatching the prompt', async () => { + const response = Response.json({ allowed: true }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(response.bodyUsed).toBe(true) + await dispose() + }) + + it('consumes malformed JSON and fails closed', async () => { + const response = new Response('{invalid', { status: 200 }) + claim.mockResolvedValue(response) + const f = fixture() + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.memory.settled).toBe(true)) + expect(response.bodyUsed).toBe(true) + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + await dispose() + }) + it.each(['dialog', 'shell', 'autocomplete'])('does not populate a %s editor', async (kind) => { const f = fixture() if (kind === 'dialog') { diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index d6df936f52db..169681217490 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -1,3 +1,4 @@ +import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' import { OPENCODE_STARTUP_PROMPT_SHA256_ENV, @@ -10,6 +11,7 @@ import { export function getOpenCodeStartupPromptSource(): string { return String.raw` const parseEndpoint = ${parseAgentHookEndpointFile.toString()}; +const cancelUnreadResponseBody = ${cancelUnreadResponseBody.toString()}; async function claimStartupPrompt(nonce, digest, endpoint) { const { readFile, stat } = await import("node:fs/promises"); if ((await stat(endpoint)).size > 4096) return false; @@ -20,9 +22,13 @@ async function claimStartupPrompt(nonce, digest, endpoint) { method: "POST", headers: { "content-type": "application/json", "x-orca-agent-hook-token": coords.token }, body: JSON.stringify({ nonce, digest }), signal: AbortSignal.timeout(1000) }); - if (!response.ok) return false; - const result = await response.json(); - return result.allowed === true ? true : result.pending === true ? "pending" : false; + try { + if (!response.ok) return false; + const result = await response.json(); + return result.allowed === true ? true : result.pending === true ? "pending" : false; + } finally { + await cancelUnreadResponseBody(response); + } } async function submitStartupPrompt(ctx) { const noop = async () => {}; diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fde..18ac3b4683e9 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -1,3 +1,4 @@ +import { openCodeHookServiceModuleMock } from '../ipc/pty-ipc-mock-registry' import { settledWriteStub } from './settled-pty-write-stub' import { describe, expect, it, vi } from 'vitest' import { setPtyHostBindings } from '../ipc/pty-host-bindings' @@ -48,18 +49,7 @@ vi.mock('node-pty', () => ({ }) })) -vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - }, - openCode2HookService: { - buildPtyEnv: () => ({}), - refreshLegacySharedPlugin: vi.fn(), - clearPty: vi.fn() - } -})) +vi.mock('../opencode/hook-service', () => openCodeHookServiceModuleMock()) vi.mock('../pi/titlebar-extension-service', () => ({ piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } @@ -175,6 +165,12 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', + 'ORCA_OPENCODE_STARTUP_PROMPT_BODY', + 'ORCA_OPENCODE_STARTUP_PROMPT_ENDPOINT', + 'ORCA_OPENCODE_STARTUP_PROMPT_NONCE', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHA256', + 'ORCA_OPENCODE_STARTUP_PROMPT_SHELL', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index d7aa946f4770..db805952a2d6 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -18,6 +18,25 @@ import type { AgentPromptActivity } from './agent-prompt-submission-verification import { readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends OrcaRuntimeWithAgentPromptRequestCorrelation { + readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { + const pty = this.ptysById.get(ptyId) + if (!pty || pty.incarnationId !== incarnationId) { + return null + } + // The runtime launch route admits identity immediately after low-level spawn returns. + if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { + return 'pending' as const + } + if ( + pty.launchIncarnationId !== incarnationId || + pty.launchToken !== launchToken || + (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') + ) { + return null + } + return this.terminalRunFacts.read(ptyId, incarnationId) + } + protected resolveAuthoritativeTerminalWaitPermission( terminal: RuntimeTerminalAgentStatusSnapshot, explicitStatus: { status: AgentStatus; updatedAt: number } | null, diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index cdfb66c95d55..5a471276d85d 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -244,25 +244,6 @@ export class OrcaRuntimeWithRuntimeId { readonly terminalRunFacts = new TerminalRunFactsRegister() - readOpenCodeStartupPromptOwner(ptyId: string, incarnationId: string, launchToken: string) { - const pty = this.ptysById.get(ptyId) - if (!pty || pty.incarnationId !== incarnationId) { - return null - } - // The runtime launch route admits identity immediately after low-level spawn returns. - if (pty.launchToken === null && pty.launchAgent === null && pty.launchIncarnationId === null) { - return 'pending' as const - } - if ( - pty.launchIncarnationId !== incarnationId || - pty.launchToken !== launchToken || - (pty.launchAgent !== 'opencode' && pty.launchAgent !== 'opencode2') - ) { - return null - } - return this.terminalRunFacts.read(ptyId, incarnationId) - } - /** Both spawn-commit funnels report each committed process here, once. */ noteTerminalSpawnCommit(commit: TerminalSpawnCommit, expectedSourceBinding?: unknown): void { this.terminalRunFacts.recordSpawnCommit(commit, expectedSourceBinding) diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index c761ccf0409f..be618a8605d7 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -78,14 +78,28 @@ describe('headless PTY registry hydration ordering', () => { const runtime = source.indexOf('const runtime = new OrcaRuntimeService(') const identityReader = source.indexOf('readObservedAgentStatusPaneIdentity:', runtime) const identitySubscription = source.indexOf('agentHookServer.subscribeEnrichedStatus(') - const hooksEnabled = source.indexOf('if (isAgentStatusHooksEnabled(', identitySubscription) + const hookStart = source.indexOf('await agentHookServer.start(', identitySubscription) + const settingsListener = source.indexOf('profileStore.onSettingsChanged(', hookStart) + const daemon = source.indexOf('await startOrcadDaemon()', hookStart) const identityFlush = source.indexOf('observedStatusCapture.attach(runtime)', runtime) expect(runtime).toBeGreaterThanOrEqual(0) expect(identityReader).toBeGreaterThan(runtime) expect(identitySubscription).toBeGreaterThanOrEqual(0) expect(identitySubscription).toBeLessThan(runtime) - expect(hooksEnabled).toBeGreaterThan(identitySubscription) + expect(hookStart).toBeGreaterThan(identitySubscription) + expect(settingsListener).toBeGreaterThan(hookStart) + expect(daemon).toBeGreaterThan(settingsListener) + expect(runtime).toBeGreaterThan(daemon) + expect(source.slice(identitySubscription, hookStart)).not.toContain( + 'if (isAgentStatusHooksEnabled(' + ) + expect(source.slice(hookStart, settingsListener)).toContain( + 'statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings())' + ) + expect(source.slice(settingsListener, daemon)).toContain( + 'agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings))' + ) expect(identityFlush).toBeGreaterThan(runtime) expect(source.slice(identitySubscription, runtime)).toContain( 'observedStatusCapture.observe(enriched)' From ed03a220321744bfd2add11eb560982c0e2df646 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 10:48:46 -0700 Subject: [PATCH 72/93] test(opencode): wait for malformed claim retries before expiring intent Observe real endpoint I/O completion under fake timers before forcing expiry. --- src/main/opencode/opencode-startup-prompt-source.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 5623ec836ae0..7ada2da61e27 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -121,6 +121,10 @@ describe('installed-version native prompt intent plugin', () => { const f = fixture() const dispose = await setup(f.ctx) await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => { + expect(response.bodyUsed).toBe(true) + expect(claim.mock.calls.length).toBeGreaterThanOrEqual(2) + }) expect(f.memory.settled).toBe(false) f.memory.expiresAt = Date.now() await vi.advanceTimersByTimeAsync(100) From 6a83da378864a4f64937112312834960b6f9700d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:58:11 -0700 Subject: [PATCH 73/93] test: initialize Claude prompt state in output retention fixture --- .../claude/claude-child-work-evidence-retention.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 2ba58138c243..613c2694ac74 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence' +import { ClaudePromptRegistry } from './claude-prompt-registry' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeToolResults, @@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => { translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 }) const join = vi.spyOn(Array.prototype, 'join') const evidence = drainClaudeChildWork( - { childWork: new ClaudeChildWorkDecoder(), translator }, + { + childWork: new ClaudeChildWorkDecoder(), + prompts: new ClaudePromptRegistry(), + translator + }, message, 1_000 ) From 049377642ddb3d39ad2e19be950fb6cd191a947f Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 15:04:28 -0700 Subject: [PATCH 74/93] Verify OpenCode catalog model launches and preserve current launch behavior --- .../orca-runtime-create-managed-worktree.ts | 26 +++++-------------- .../runtime/runtime-worktree-agent-startup.ts | 19 ++++++++++++++ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index 2858596ca82a..fc36a9c8c2c3 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -14,15 +14,14 @@ import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' import { assertOpenCodeModelWorktreeCreateSupported } from '../opencode/opencode-model-startup-plan' +import { resolveWorktreeCreateAgentStartup } from './runtime-worktree-agent-startup' import type { RuntimeWorkspaceCreateEvents } from '../workspace-create-telemetry' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { async createManagedWorktree( args: RuntimeManagedWorktreeCreateArgs ): Promise { - // Why a holder fired in `finally`: consuming a prepared checkout empties a pool slot, so a - // create that fails anywhere after that — include copy, push target, terminal startup — must - // still arm the replacement. On success it fires last, once the startup terminals are up. + // Re-arm a consumed checkout after terminal startup, including failed creates. const rearm: PreparationRearmHolder = { fire: () => {} } try { return await trackRuntimeWorkspaceCreate(args, (events) => @@ -64,19 +63,9 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ) { throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') } - const agentStartup = - !args.startup && args.startupAgent - ? this.buildStartupForAgent( - repo, - args.startupAgent, - args.startupPrompt, - args.startupLaunchPreferences, - { - ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), - ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}) - } - ) - : null + const agentStartup = resolveWorktreeCreateAgentStartup(args, (...inputs) => + this.buildStartupForAgent(repo, ...inputs) + ) const draftStartup = !args.startup && !agentStartup && args.startupDraft ? await this.buildStartupForDraft( @@ -94,10 +83,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork draftStartup?.agent ?? (requestedAgentEnabled ? requestedAgent : undefined)) const effectiveDraftPaste = args.startupDraftPaste ?? draftStartup?.draftPaste - // Resolve the execution host once, shared with the `worktrees:create` IPC entry point so the - // two cannot answer differently for the same repo. Reading the raw `connectionId` field routes - // an `executionHostId: 'ssh:*'`-only repo down the local path, which runs `git worktree add` on - // the client against a remote path. + // Match IPC routing: executionHostId-only SSH repos must not create locally. const createRoute = resolveWorktreeCreateRoute(repo) if (isFolderRepo(repo)) { // A folder workspace is a registration, not a filesystem create, so it is host-agnostic. diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index a65f25273573..a57beb7efcd4 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -14,6 +14,7 @@ import { detectRemoteAgents } from '../preflight/agent-detection' import type { RuntimeStore } from './runtime-store-contract' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' export type WorktreeStartupDraftPaste = { agent: TuiAgent; content: string } export type WorktreeStartupFollowup = { expectedProcess: string; prompt: string } @@ -173,3 +174,21 @@ export function buildWorktreeStartupForAgent( : {}) } } + +export function resolveWorktreeCreateAgentStartup( + args: RuntimeManagedWorktreeCreateArgs, + build: ( + agent: TuiAgent, + prompt: string | undefined, + preferences: AgentLaunchPreferences | undefined, + inputs: { agentArgs?: string | null; launchSource?: string } + ) => { agent: TuiAgent; startup: WorktreeStartupLaunch; followup?: WorktreeStartupFollowup } +) { + if (args.startup || !args.startupAgent) { + return null + } + return build(args.startupAgent, args.startupPrompt, args.startupLaunchPreferences, { + ...(args.startupAgentArgs !== undefined ? { agentArgs: args.startupAgentArgs } : {}), + ...(args.startupLaunchSource ? { launchSource: args.startupLaunchSource } : {}) + }) +} From 3366421a2807a4a27c494049da67c86ab46bc214 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 15:27:52 -0700 Subject: [PATCH 75/93] Verify OpenCode catalog model launches and preserve current launch behavior --- .../components/terminal-pane/ipc-pty-opencode-model.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts b/src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts index 1f8ccd329783..631d965b91b8 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-opencode-model.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { callRuntimeRpc } from '../../runtime/runtime-rpc-client' import { spawnIpcPty } from './ipc-pty-spawn-request' import type { IpcPtyTransportOptions } from './pty-transport-types' @@ -59,9 +60,10 @@ describe('local OpenCode model launch authority', () => { vi.mocked(callRuntimeRpc).mockRejectedValueOnce(new Error('reply lost')) await spawnIpcPty(transport, connect) await spawnIpcPty(transport, connect) + const operationRequest = z.object({ clientOperationId: z.string() }) const ids = vi .mocked(callRuntimeRpc) - .mock.calls.map((call) => Reflect.get(call[2] ?? {}, 'clientOperationId')) + .mock.calls.map((call) => operationRequest.parse(call[2]).clientOperationId) expect(ids).toHaveLength(3) expect(new Set(ids).size).toBe(1) expect(ids[0]).toMatch(/^\d{13}-[0-9a-f]{32}$/) From 94aa46e22dcc65ef628a5987bc2bc7ac1445cdcd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:32:07 -0700 Subject: [PATCH 76/93] Refuse unverified new-worktree OpenCode models and complete host audits --- .../host-agent-startup-call-sites.txt | 5 +- src/main/global-fetch-call-site-audit.test.ts | 2 + ...ncode-launch-model-context-cleanup.test.ts | 16 ++++++ .../opencode/opencode-launch-model-context.ts | 12 ++-- ...a-runtime-get-terminal-interactive-wait.ts | 3 +- .../worker/local-worker-start.ts | 13 +---- .../worker/worker-opencode-model-preflight.ts | 12 ++-- .../worker/workers-new-worktree.test.ts | 55 ++++++++++++++----- 8 files changed, 79 insertions(+), 39 deletions(-) diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt index de078fd731e9..193c69c27657 100644 --- a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -2,9 +2,8 @@ # attributes a fresh agent the host builds; carries agentStartedTelemetry # resume continues an existing agent session; not a new start # mobile-followup the phone's session-tab launch; attribution is a separate follow-up -# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. -src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes -src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes +# The shared execution-host builder also serves bare commands; only fresh-agent callers supply attribution. +src/main/opencode/opencode-model-startup-plan.ts 2 attributes src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/global-fetch-call-site-audit.test.ts b/src/main/global-fetch-call-site-audit.test.ts index 664bbaf1aa32..2be65fd1c73a 100644 --- a/src/main/global-fetch-call-site-audit.test.ts +++ b/src/main/global-fetch-call-site-audit.test.ts @@ -22,6 +22,8 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map([ ['main/gitea/client.ts', 1], // Generated OpenCode claim source consumes JSON or cancels its body in finally. ['main/opencode/opencode-startup-prompt-source.ts', 1], + // Authenticated loopback preflight consumes bounded JSON and cancels every body in finally. + ['main/opencode/opencode-launch-model-context.ts', 1], ['main/orca-profiles/profile-cloud-client.ts', 1], ['main/orca-profiles/profile-cloud-org-members-client.ts', 1], ['main/rate-limits/codex-fetcher.ts', 3], diff --git a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts index c56e4d9ce476..4acf1ba72de4 100644 --- a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts +++ b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts @@ -8,6 +8,7 @@ import { signalProcessTree } from '../../shared/child-process/process-tree-termination' import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' +import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' vi.mock('../../shared/child-process/run-process', () => ({ spawnProcess: vi.fn() })) vi.mock('../../shared/child-process/process-tree-termination', () => ({ @@ -15,6 +16,8 @@ vi.mock('../../shared/child-process/process-tree-termination', () => ({ forceTerminateProcessTree: vi.fn() })) +vi.mock('../../shared/fetch-response-body', () => ({ readFetchResponseJsonWithinLimit: vi.fn() })) + class ProbeChild extends ChildProcess { override stdin = new PassThrough() override stdout = new PassThrough() @@ -54,6 +57,7 @@ const options = { executable: '/private/opencode', cwd: directory, env: {} } describe('OpenCode model probe termination evidence', () => { beforeEach(() => { vi.resetAllMocks() + vi.mocked(readFetchResponseJsonWithinLimit).mockImplementation((response) => response.json()) child = new ProbeChild() closeDuringFetch = false vi.mocked(spawnProcess).mockImplementation(() => { @@ -87,6 +91,18 @@ describe('OpenCode model probe termination evidence', () => { vi.unstubAllGlobals() }) + it('cancels all unread responses if the bounded body reader fails', async () => { + const cancel = vi.fn() + vi.mocked(readFetchResponseJsonWithinLimit).mockRejectedValue(new Error('body_reader_failed')) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 })) + ) + expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() + expect(cancel).toHaveBeenCalledTimes(4) + expect(signalProcessTree).toHaveBeenCalledOnce() + }) + it('accepts an already-closed Windows probe without signaling its former pid', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) closeDuringFetch = true diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts index a422cdb5917d..ce1098d8acfb 100644 --- a/src/main/opencode/opencode-launch-model-context.ts +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -9,6 +9,7 @@ import { import { withTimeout } from '../../shared/promise-timeout-fallback' import { createOutputSink } from '../../shared/child-process/bounded-output-sink' import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' const model = z.object({ id: z.string(), providerID: z.string() }) const availableModel = model.extend({ enabled: z.boolean() }) @@ -155,11 +156,14 @@ export async function probeOpenCodeLaunchModelContext(options: { redirect: 'error', signal }) - if (!response.ok) { - await response.body?.cancel() - throw new Error('OpenCode model preflight refused') + try { + if (!response.ok) { + throw new Error('OpenCode model preflight refused') + } + return await readFetchResponseJsonWithinLimit(response, 1_048_576) + } finally { + await cancelUnreadResponseBody(response) } - return readFetchResponseJsonWithinLimit(response, 1_048_576) } const [models, agents, defaultModel, config] = await Promise.all([ read('model'), diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 46ea59afcfaf..567cbe6896d5 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -230,10 +230,9 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd async probeOrchestrationOpenCodeModelLaunchSupport(target: { worktree?: string - repo?: string model?: string }): Promise { - if (!target.model || target.repo || !target.worktree) { + if (!target.model || !target.worktree) { return false } const workspace = await this.resolveTerminalWorkspaceLaunchScope(target.worktree) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index c5c4996cd7b0..c964c6f1e17d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -73,26 +73,19 @@ export async function startLocalWorker(args: { } }) let openCodeModelLaunchSupported = false - if (launchParams.agent === 'opencode' && launchParams.model) { + if (!createsWorktree && launchParams.agent === 'opencode' && launchParams.model) { const callerWorkspaceId = await resolveDispatchCallerWorktreeId( runtime, params.from, callerSession ) - const creationParent = createsWorktree - ? await runtime.showManagedWorktree(`id:${callerWorkspaceId}`) - : undefined openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( runtime, launchParams, - createsWorktree - ? { repo: launchParams.repo ?? creationParent?.repoId } - : { - worktree: - requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree - } + { worktree: requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree } ) } + const { agent, launch } = prepareLocalWorkerStart({ params: launchParams, createsWorktree, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts index 9e6367aa72d5..af0c4aa8db7a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts @@ -8,7 +8,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' export async function probeWorkerOpenCodeModelLaunchSupport( runtime: OrcaRuntimeService, params: { agent?: string; model?: string }, - target: { worktree?: string; repo?: string } + target: { worktree?: string } ): Promise { return Boolean( params.model && @@ -27,10 +27,10 @@ export async function prepareFederationWorkerLaunchOnHost( const params = await resolveWorkerConfiguredAgentParams(args.runtime, args.params, async () => args.createsWorktree ? { repo: args.params.repo } : { worktree: args.params.worktree } ) - const openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( - args.runtime, - params, - args.createsWorktree ? { repo: params.repo } : { worktree: params.worktree } - ) + const openCodeModelLaunchSupported = + !args.createsWorktree && + (await probeWorkerOpenCodeModelLaunchSupport(args.runtime, params, { + worktree: params.worktree + })) return prepareFederationConfiguredWorkerStart({ ...args, params, openCodeModelLaunchSupported }) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index ffc74fdd7773..3e5f717c382f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -8,6 +8,8 @@ import { OrchestrationDb } from '../../../../orchestration/db' import { RpcDispatcher } from '../../../dispatcher' import type { RpcRequest } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { prepareFederationWorkerLaunchOnHost } from './worker-opencode-model-preflight' +import { FederationAttachStartParams } from '../federation/federation-start-schema' describe('orchestration new-worktree workers', () => { type CreateWorktreeResult = Awaited> @@ -189,24 +191,49 @@ describe('orchestration new-worktree workers', () => { }) }) - it('probes the creation repo before accepting an OpenCode model', async () => { - mockCreatedWorktree() + it.each(['new-child', 'new-top-level'])( + 'refuses an OpenCode model for %s without probing', + async (worktree) => { + mockCreatedWorktree() + const probe = vi + .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') + .mockResolvedValue(true) + await expect( + startWorker({ + worktree, + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' + }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) + + it('refuses a federated new-worktree model before probing its repository', async () => { const probe = vi .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') .mockResolvedValue(true) - await expect( - startWorker({ agent: 'opencode', model: 'opencode/fledge-alpha-free' }) - ).resolves.toMatchObject({ - result: { - launch: { - effective: { agent: 'opencode', model: 'opencode/fledge-alpha-free', effort: null } - } - } + const params = FederationAttachStartParams.parse({ + dispatchId: 'ctx_new', + taskId: 'task_new', + taskSpec: 'No effects', + protocolVersion: 3, + worktree: 'new-top-level', + name: 'new-worker', + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' }) - expect(probe).toHaveBeenCalledWith({ repo: 'repo', model: 'opencode/fledge-alpha-free' }) - expect(runtime.createManagedWorktree).toHaveBeenCalledWith( - expect.objectContaining({ startupLaunchPreferences: { model: 'opencode/fledge-alpha-free' } }) - ) + await expect( + prepareFederationWorkerLaunchOnHost({ runtime, params, createsWorktree: true }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) it('rejects an unsupported OpenCode model before creating a worker', async () => { From 85efe85eadcc6e28b3813eefd2c79e1b0636d189 Mon Sep 17 00:00:00 2001 From: Orca startup hydration review Date: Sat, 3 Oct 2026 18:35:23 -0700 Subject: [PATCH 77/93] Wait for OpenCode location hydration in intent startup --- .../opencode-startup-prompt-source.test.ts | 120 +++++++++++++++++- .../opencode-startup-prompt-source.ts | 9 +- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 7ada2da61e27..fcf3739dcecd 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -47,6 +47,7 @@ function fixture() { const route = { type: 'home' } const agent = vi.fn((): unknown[] | undefined => [{}]) const model = vi.fn((): unknown[] | undefined => [{}]) + const sync = vi.fn(async () => {}) const dispatch = vi.fn(() => editor.replace('')) const ctx = { app: { version: '2.0.16' }, @@ -55,9 +56,9 @@ function fixture() { keymap: { dispatch }, ui: { router: { current: () => route } }, location: { directory: '/private' }, - data: { location: { agent: { list: agent }, model: { list: model } } } + data: { location: { sync, agent: { list: agent }, model: { list: model } } } } - return { ctx, editor, input, memory, route, agent, model, dispatch } + return { ctx, editor, input, memory, route, agent, model, sync, dispatch } } beforeEach(async () => { @@ -87,6 +88,121 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it('waits for authoritative location config before claiming or selecting a model', async () => { + const f = fixture() + let configuredModel = 'unavailable-fallback' + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = () => { + configuredModel = 'configured-model' + resolve() + } + }) + ) + const selections: string[] = [] + f.dispatch.mockImplementation(() => { + selections.push(configuredModel) + f.editor.replace('') + }) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit')) + expect(selections).toEqual(['configured-model']) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.sync).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it.each(['keypress', 'paste', 'edit', 'route', 'expiry', 'dispose', 'editor', 'focus'])( + 'cancels delayed location hydration on %s before any claim', + async (reason) => { + const f = fixture() + let release = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + expect(claim).not.toHaveBeenCalled() + if (reason === 'keypress' || reason === 'paste') { + f.input.emit(reason) + } + if (reason === 'edit') { + f.editor.replace('typed') + f.editor.replace('') + } + if (reason === 'route') { + f.route.type = 'session' + } + if (reason === 'expiry') { + f.memory.expiresAt = Date.now() + } + if (reason === 'dispose') { + await dispose() + } + if (reason === 'editor') { + f.ctx.renderer.currentFocusedEditor = new Editor() + } + if (reason === 'focus') { + f.editor.focused = false + } + await vi.advanceTimersByTimeAsync(100) + release() + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.editor.plainText).toBe('') + if (reason !== 'focus') { + expect(f.memory.settled).toBe(true) + } + } finally { + await dispose() + } + expect(f.input.listenerCount('keypress')).toBe(0) + expect(f.editor.listenerCount('line-info-change')).toBe(0) + } + ) + + it('fails closed when authoritative location sync rejects', async () => { + const f = fixture() + f.sync.mockRejectedValue(new Error('location unavailable')) + const dispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(f.memory.settled).toBe(true) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + + it('fails closed when authoritative location sync is missing', async () => { + const f = fixture() + const { sync: _sync, ...location } = f.ctx.data.location + const dispose = await setup({ ...f.ctx, data: { location } }) + await vi.advanceTimersByTimeAsync(500) + expect(claim).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.input.listenerCount('keypress')).toBe(0) + await dispose() + }) + it.each(['non-ok', 'json-rejected'])('cancels unread %s claim bodies', async (reason) => { const cancelled = vi.fn() const response = cancelTrackingResponse(reason === 'non-ok' ? 503 : 200, cancelled) diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index a68a967f4266..77fc15d38a98 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -45,13 +45,14 @@ async function submitStartupPrompt(ctx) { if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || typeof ctx.ui?.router?.current !== "function" || + typeof ctx.data?.location?.sync !== "function" || typeof ctx.data?.location?.agent?.list !== "function" || typeof ctx.data?.location?.model?.list !== "function") return noop; const [memory, setMemory] = ctx.storage.memory("startup-prompt", { initial: { settled: false, expiresAt: Date.now() + 20000 } }); if (memory.settled) return noop; - let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, locationReady = false; const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && candidate.traits.role === "prompt" && !candidate.traits.status && candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; @@ -97,7 +98,7 @@ async function submitStartupPrompt(ctx) { seen = true; const agents = ctx.data.location.agent.list(ctx.location); const models = ctx.data.location.model.list(ctx.location); - if (!editor.focused || !agents?.length || !models?.length) return; + if (!locationReady || !editor.focused || !agents?.length || !models?.length) return; if (claiming) return; claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); @@ -120,6 +121,10 @@ async function submitStartupPrompt(ctx) { } catch { settle(); } }, 100); timer.unref?.(); + // Catalogs can be present before the configured model has hydrated. + void ctx.data.location.sync(ctx.location).then(() => { + if (!disposed && !memory.settled) locationReady = true; + }, settle); return dispose; } catch { settle(); From ae43d87999398b57cbb1df50159e78385928fc63 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 18:41:40 -0700 Subject: [PATCH 78/93] Refuse unverified new-worktree OpenCode model launches and record startup attribution --- .../host-agent-startup-call-sites.txt | 5 +- ...a-runtime-get-terminal-interactive-wait.ts | 3 +- .../worker/local-worker-start.ts | 13 +---- .../worker/worker-opencode-model-preflight.ts | 12 ++-- .../worker/workers-new-worktree.test.ts | 55 ++++++++++++++----- 5 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt index de078fd731e9..193c69c27657 100644 --- a/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt +++ b/src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt @@ -2,9 +2,8 @@ # attributes a fresh agent the host builds; carries agentStartedTelemetry # resume continues an existing agent session; not a new start # mobile-followup the phone's session-tab launch; attribution is a separate follow-up -# The resolve-worktree-removal-target call also serves a bare typed command, which stays unattributed. -src/main/runtime/orca-runtime-create-agent-session.ts 2 attributes -src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts 1 attributes +# The shared execution-host builder also serves bare commands; only fresh-agent callers supply attribution. +src/main/opencode/opencode-model-startup-plan.ts 2 attributes src/main/runtime/runtime-worktree-agent-startup.ts 3 attributes src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts 1 resume src/main/runtime/orca-runtime-resolve-mobile-session-terminal-command.ts 1 mobile-followup diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index 46ea59afcfaf..567cbe6896d5 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -230,10 +230,9 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd async probeOrchestrationOpenCodeModelLaunchSupport(target: { worktree?: string - repo?: string model?: string }): Promise { - if (!target.model || target.repo || !target.worktree) { + if (!target.model || !target.worktree) { return false } const workspace = await this.resolveTerminalWorkspaceLaunchScope(target.worktree) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index c5c4996cd7b0..c964c6f1e17d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -73,26 +73,19 @@ export async function startLocalWorker(args: { } }) let openCodeModelLaunchSupported = false - if (launchParams.agent === 'opencode' && launchParams.model) { + if (!createsWorktree && launchParams.agent === 'opencode' && launchParams.model) { const callerWorkspaceId = await resolveDispatchCallerWorktreeId( runtime, params.from, callerSession ) - const creationParent = createsWorktree - ? await runtime.showManagedWorktree(`id:${callerWorkspaceId}`) - : undefined openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( runtime, launchParams, - createsWorktree - ? { repo: launchParams.repo ?? creationParent?.repoId } - : { - worktree: - requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree - } + { worktree: requestedWorktree === 'current' ? `id:${callerWorkspaceId}` : requestedWorktree } ) } + const { agent, launch } = prepareLocalWorkerStart({ params: launchParams, createsWorktree, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts index 9e6367aa72d5..af0c4aa8db7a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-opencode-model-preflight.ts @@ -8,7 +8,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' export async function probeWorkerOpenCodeModelLaunchSupport( runtime: OrcaRuntimeService, params: { agent?: string; model?: string }, - target: { worktree?: string; repo?: string } + target: { worktree?: string } ): Promise { return Boolean( params.model && @@ -27,10 +27,10 @@ export async function prepareFederationWorkerLaunchOnHost( const params = await resolveWorkerConfiguredAgentParams(args.runtime, args.params, async () => args.createsWorktree ? { repo: args.params.repo } : { worktree: args.params.worktree } ) - const openCodeModelLaunchSupported = await probeWorkerOpenCodeModelLaunchSupport( - args.runtime, - params, - args.createsWorktree ? { repo: params.repo } : { worktree: params.worktree } - ) + const openCodeModelLaunchSupported = + !args.createsWorktree && + (await probeWorkerOpenCodeModelLaunchSupport(args.runtime, params, { + worktree: params.worktree + })) return prepareFederationConfiguredWorkerStart({ ...args, params, openCodeModelLaunchSupported }) } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index ffc74fdd7773..3e5f717c382f 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -8,6 +8,8 @@ import { OrchestrationDb } from '../../../../orchestration/db' import { RpcDispatcher } from '../../../dispatcher' import type { RpcRequest } from '../../../core' import { ORCHESTRATION_METHODS } from '../../orchestration' +import { prepareFederationWorkerLaunchOnHost } from './worker-opencode-model-preflight' +import { FederationAttachStartParams } from '../federation/federation-start-schema' describe('orchestration new-worktree workers', () => { type CreateWorktreeResult = Awaited> @@ -189,24 +191,49 @@ describe('orchestration new-worktree workers', () => { }) }) - it('probes the creation repo before accepting an OpenCode model', async () => { - mockCreatedWorktree() + it.each(['new-child', 'new-top-level'])( + 'refuses an OpenCode model for %s without probing', + async (worktree) => { + mockCreatedWorktree() + const probe = vi + .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') + .mockResolvedValue(true) + await expect( + startWorker({ + worktree, + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' + }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createManagedWorktree).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) + + it('refuses a federated new-worktree model before probing its repository', async () => { const probe = vi .spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport') .mockResolvedValue(true) - await expect( - startWorker({ agent: 'opencode', model: 'opencode/fledge-alpha-free' }) - ).resolves.toMatchObject({ - result: { - launch: { - effective: { agent: 'opencode', model: 'opencode/fledge-alpha-free', effort: null } - } - } + const params = FederationAttachStartParams.parse({ + dispatchId: 'ctx_new', + taskId: 'task_new', + taskSpec: 'No effects', + protocolVersion: 3, + worktree: 'new-top-level', + name: 'new-worker', + repo: 'repo', + agent: 'opencode', + model: 'opencode/fledge-alpha-free' }) - expect(probe).toHaveBeenCalledWith({ repo: 'repo', model: 'opencode/fledge-alpha-free' }) - expect(runtime.createManagedWorktree).toHaveBeenCalledWith( - expect.objectContaining({ startupLaunchPreferences: { model: 'opencode/fledge-alpha-free' } }) - ) + await expect( + prepareFederationWorkerLaunchOnHost({ runtime, params, createsWorktree: true }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(probe).not.toHaveBeenCalled() + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) it('rejects an unsupported OpenCode model before creating a worker', async () => { From 931309be76772b73612c3c98ee8e5f1c3ccf0e27 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:34:03 -0700 Subject: [PATCH 79/93] fix(opencode): bind startup readiness to the composer location --- .../opencode-startup-prompt-source.test.ts | 120 +++++++++++++++++- .../opencode-startup-prompt-source.ts | 32 +++-- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index fcf3739dcecd..2e505ee2a8f0 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -40,6 +40,8 @@ class Editor extends EventEmitter { } } +type FixtureLocation = { directory: string; workspaceID?: string } + function fixture() { const editor = new Editor() const input = new EventEmitter() @@ -47,7 +49,7 @@ function fixture() { const route = { type: 'home' } const agent = vi.fn((): unknown[] | undefined => [{}]) const model = vi.fn((): unknown[] | undefined => [{}]) - const sync = vi.fn(async () => {}) + const sync = vi.fn(async (_location: FixtureLocation) => {}) const dispatch = vi.fn(() => editor.replace('')) const ctx = { app: { version: '2.0.16' }, @@ -88,6 +90,122 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + it('waits for authoritative location config before claiming or selecting a model', async () => { const f = fixture() let configuredModel = 'unavailable-fallback' diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 77fc15d38a98..4bb378611029 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -52,7 +52,10 @@ async function submitStartupPrompt(ctx) { initial: { settled: false, expiresAt: Date.now() + 20000 } }); if (memory.settled) return noop; - let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, locationReady = false; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && candidate.traits.role === "prompt" && !candidate.traits.status && candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; @@ -96,9 +99,24 @@ async function submitStartupPrompt(ctx) { if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; if (editor.plainText !== "") return settle(); seen = true; - const agents = ctx.data.location.agent.list(ctx.location); - const models = ctx.data.location.model.list(ctx.location); - if (!locationReady || !editor.focused || !agents?.length || !models?.length) return; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; if (claiming) return; claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); @@ -106,6 +124,7 @@ async function submitStartupPrompt(ctx) { if (allowed === "pending") return; if (!allowed) return settle(); if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); setMemory((draft) => { draft.settled = true; }); @@ -114,6 +133,7 @@ async function submitStartupPrompt(ctx) { try { editor.insertText(prompt); if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || !editor.focused || !isComposer(editor) || !matches(editor)) return; ctx.keymap.dispatch("prompt.submit"); @@ -121,10 +141,6 @@ async function submitStartupPrompt(ctx) { } catch { settle(); } }, 100); timer.unref?.(); - // Catalogs can be present before the configured model has hydrated. - void ctx.data.location.sync(ctx.location).then(() => { - if (!disposed && !memory.settled) locationReady = true; - }, settle); return dispose; } catch { settle(); From 1dc953cf8c73e20773ef7fe626b4b6857784b91e Mon Sep 17 00:00:00 2001 From: Orca startup hydration review Date: Sat, 3 Oct 2026 19:41:38 -0700 Subject: [PATCH 80/93] Bind OpenCode startup readiness to the current location in intent startup --- .../opencode-startup-prompt-source.test.ts | 120 +++++++++++++++++- .../opencode-startup-prompt-source.ts | 32 +++-- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index fcf3739dcecd..2e505ee2a8f0 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -40,6 +40,8 @@ class Editor extends EventEmitter { } } +type FixtureLocation = { directory: string; workspaceID?: string } + function fixture() { const editor = new Editor() const input = new EventEmitter() @@ -47,7 +49,7 @@ function fixture() { const route = { type: 'home' } const agent = vi.fn((): unknown[] | undefined => [{}]) const model = vi.fn((): unknown[] | undefined => [{}]) - const sync = vi.fn(async () => {}) + const sync = vi.fn(async (_location: FixtureLocation) => {}) const dispatch = vi.fn(() => editor.replace('')) const ctx = { app: { version: '2.0.16' }, @@ -88,6 +90,122 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it('waits for the current home location after the startup directory changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private/home/private-folder' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let releaseStartup = () => {} + let releaseHome = () => {} + let selectedModel = 'opencode/mimo-v2.6-flash-free' + const selections: string[] = [] + f.sync.mockImplementation( + (target) => + new Promise((resolve) => { + if (target.directory.endsWith('/private-folder')) { + releaseStartup = resolve + } else { + releaseHome = () => { + selectedModel = 'private-proof/model-a' + resolve() + } + } + }) + ) + f.dispatch.mockImplementation(() => { + selections.push(selectedModel) + f.editor.replace('') + }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private/home' } + releaseStartup() + await vi.advanceTimersByTimeAsync(300) + expect(claim).not.toHaveBeenCalled() + expect(selections).toEqual([]) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + releaseHome() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(selections).toEqual(['private-proof/model-a']) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('waits for a concrete location before starting authoritative hydration', async () => { + const f = fixture() + let location: FixtureLocation | undefined + Object.defineProperty(f.ctx, 'location', { get: () => location }) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(300) + expect(f.sync).not.toHaveBeenCalled() + expect(claim).not.toHaveBeenCalled() + location = { directory: '/private/home' } + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(location) + } finally { + await dispose() + } + }) + + it('keeps readiness scoped to the workspace as well as the directory', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private', workspaceID: 'first' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let release = () => {} + f.sync.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(100) + location = { directory: '/private', workspaceID: 'second' } + release() + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(f.dispatch).toHaveBeenCalledTimes(1)) + expect(f.sync).toHaveBeenCalledTimes(2) + expect(f.sync).toHaveBeenLastCalledWith(location) + expect(claim).toHaveBeenCalledTimes(1) + } finally { + await dispose() + } + }) + + it('refuses a granted claim after the composer location changes', async () => { + const f = fixture() + let location: FixtureLocation = { directory: '/private' } + Object.defineProperty(f.ctx, 'location', { get: () => location }) + let grant = () => {} + claim.mockImplementation( + () => + new Promise((resolve) => { + grant = () => resolve({ ok: true, json: async () => ({ allowed: true }) }) + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + location = { directory: '/private/other' } + grant() + await vi.advanceTimersByTimeAsync(500) + expect(insert).not.toHaveBeenCalled() + expect(f.dispatch).not.toHaveBeenCalled() + expect(f.memory.settled).toBe(true) + } finally { + await dispose() + } + }) + it('waits for authoritative location config before claiming or selecting a model', async () => { const f = fixture() let configuredModel = 'unavailable-fallback' diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 77fc15d38a98..4bb378611029 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -52,7 +52,10 @@ async function submitStartupPrompt(ctx) { initial: { settled: false, expiresAt: Date.now() + 20000 } }); if (memory.settled) return noop; - let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, locationReady = false; + let timer, editor, seen = false, disposed = false, canceled = false, createHash, requestId, claiming = false, hydrating = false, readyLocation; + // Home can change location after plugin setup. + const locationKey = (location) => typeof location?.directory === "string" && location.directory ? + JSON.stringify([location.directory, location.workspaceID]) : undefined; const isComposer = (candidate) => candidate?.traits?.owner === "opencode" && candidate.traits.role === "prompt" && !candidate.traits.status && candidate.traits.capture?.length === 1 && candidate.traits.capture[0] === "tab"; @@ -96,9 +99,24 @@ async function submitStartupPrompt(ctx) { if (typeof editor?.plainText !== "string" || typeof editor?.insertText !== "function") return; if (editor.plainText !== "") return settle(); seen = true; - const agents = ctx.data.location.agent.list(ctx.location); - const models = ctx.data.location.model.list(ctx.location); - if (!locationReady || !editor.focused || !agents?.length || !models?.length) return; + const location = ctx.location; + const key = locationKey(location); + if (!key) return; + if (readyLocation !== key) { + readyLocation = undefined; + if (!hydrating) { + hydrating = true; + const ref = { directory: location.directory, workspaceID: location.workspaceID }; + void ctx.data.location.sync(ref).then(() => { + hydrating = false; + if (!disposed && !memory.settled && locationKey(ctx.location) === key) readyLocation = key; + }, settle); + } + return; + } + const agents = ctx.data.location.agent.list(location); + const models = ctx.data.location.model.list(location); + if (!editor.focused || !agents?.length || !models?.length) return; if (claiming) return; claiming = true; const allowed = await claimStartupPrompt(nonce, digest, endpoint, requestId); @@ -106,6 +124,7 @@ async function submitStartupPrompt(ctx) { if (allowed === "pending") return; if (!allowed) return settle(); if (disposed || memory.settled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || !editor.focused || !isComposer(editor) || editor.plainText !== "") return settle(); setMemory((draft) => { draft.settled = true; }); @@ -114,6 +133,7 @@ async function submitStartupPrompt(ctx) { try { editor.insertText(prompt); if (disposed || canceled || Date.now() >= memory.expiresAt || + locationKey(ctx.location) !== key || ctx.ui.router.current()?.type !== "home" || ctx.renderer.currentFocusedEditor !== editor || !editor.focused || !isComposer(editor) || !matches(editor)) return; ctx.keymap.dispatch("prompt.submit"); @@ -121,10 +141,6 @@ async function submitStartupPrompt(ctx) { } catch { settle(); } }, 100); timer.unref?.(); - // Catalogs can be present before the configured model has hydrated. - void ctx.data.location.sync(ctx.location).then(() => { - if (!disposed && !memory.settled) locationReady = true; - }, settle); return dispose; } catch { settle(); From 64c43532a65dab1998d2afb3ecf2be336951cefe Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 19:57:49 -0700 Subject: [PATCH 81/93] Restore the owning Orca CLI path after shell profiles --- .../daemon-bash-rcfile.txt | 6 +++ .../daemon-zsh-zshenv.txt | 6 +++ .../local-bash-rcfile.txt | 6 +++ .../local-zsh-zshenv.txt | 6 +++ .../relay-bash-rcfile.txt | 6 +++ .../relay-zsh-zshenv.txt | 6 +++ src/main/cli/orca-cli-child-path.test.ts | 16 ++++++- src/main/cli/orca-cli-child-path.ts | 8 ++++ .../daemon/daemon-bash-shell-ready-rcfile.ts | 2 + .../daemon/shell-ready-bash-wrapper.test.ts | 44 ++++++++++++++++++- .../local-pty-shell-ready-bash-rcfile.ts | 2 + src/main/providers/ssh-pty-spawn-env.test.ts | 43 ++++++++++++++++++ src/main/providers/ssh-pty-spawn-env.ts | 5 +++ .../shell-startup-feature-channel.test.ts | 33 ++++++++++++++ src/main/shell-startup-features.ts | 3 +- src/main/zsh-startup-wrapper-builder.ts | 2 + src/relay/pty-shell-overlay-wrappers.ts | 2 + src/shared/orca-cli-shell-path.ts | 6 +++ 18 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 src/main/providers/ssh-pty-spawn-env.test.ts create mode 100644 src/shared/orca-cli-shell-path.ts diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt index 2d161a012898..c8de61a9fdac 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-bash-rcfile.txt @@ -30,6 +30,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 2187aaca0f02..51ede33c5298 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -70,6 +70,12 @@ __orca_deferred_init() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt index fd151e03b12e..05f3da594e03 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-bash-rcfile.txt @@ -33,6 +33,12 @@ __orca_restore_agent_teams_path() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi if [ -n "${ORCA_WSL_CLI_DIR:-}" ]; then if [ -x "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" ]; then export PATH="$ORCA_WSL_CLI_DIR${PATH:+:$PATH}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 7172b28d2dc2..3d11da446627 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -77,6 +77,12 @@ __orca_deferred_init() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt index 3042bb11df44..e9cc47d8361f 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-bash-rcfile.txt @@ -26,6 +26,12 @@ fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}" [[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac +fi # Why: OMP does not auto-load Orca's managed status extension; wrap only # interactive launch invocations so subcommands such as `omp config` keep # their normal argv shape. diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 102534564b78..da5ae3cc2feb 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -43,6 +43,12 @@ __orca_deferred_init() { precmd_functions=(${precmd_functions:#__orca_deferred_init}) if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 727732dd64c0..7440b1a9ad82 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -27,6 +27,7 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'linux' }) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/local/bin:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(SHIM_DIR) expect(shim.ensureLinuxTerminalOrcaCliShimDir).toHaveBeenCalledWith({ userDataPath: USER_DATA }) @@ -44,13 +45,14 @@ describe('prependOrcaCliDirToChildPath', () => { it('leaves packaged Linux PATH untouched when no shim could be written', () => { shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) - const env: Record = { PATH: '/usr/bin' } + const env: Record = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/old-host/cli' } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, platform: 'linux' }) expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('leads packaged macOS PATH with the bundled CLI dir', () => { @@ -62,11 +64,16 @@ describe('prependOrcaCliDirToChildPath', () => { platform: 'darwin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe(join(RESOURCES, 'bin')) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) it('leads packaged Windows PATH with the bundled CLI dir under the env block spelling', () => { - const env: Record = { Path: 'C:\\Windows\\System32' } + const env: Record = { + Path: 'C:\\Windows\\System32', + ORCA_CLI_BIN_DIR: '/parent-host/cli', + ORCA_WSL_CLI_DIR: '/guest/orca/bin' + } prependOrcaCliDirToChildPath(env, { isPackaged: true, userDataPath: USER_DATA, @@ -75,6 +82,8 @@ describe('prependOrcaCliDirToChildPath', () => { }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows\\System32`) expect(env.PATH).toBeUndefined() + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(env.ORCA_WSL_CLI_DIR).toBe('/guest/orca/bin') }) it('leaves a packaged darwin/win32 PATH alone with no resources root', () => { @@ -101,6 +110,9 @@ describe('prependOrcaCliDirToChildPath', () => { platform }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}${pathDelimiter}/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe( + platform === 'win32' ? undefined : join(USER_DATA, 'cli', 'bin') + ) expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index 2a8136c367fc..a15943827cc4 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -39,12 +39,16 @@ export function prependOrcaCliDirToChildPath( opts: OrcaCliChildPathOptions ): string | null { const platform = opts.platform ?? process.platform + delete env.ORCA_CLI_BIN_DIR // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. const pathDelimiter = platform === 'win32' ? ';' : delimiter // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') + if (platform !== 'win32') { + env.ORCA_CLI_BIN_DIR = devCliBin + } const inheritedPath = readInheritedPath(env, platform) // Why: an empty PATH segment resolves as `.` in some shells (commands run from cwd); avoid a trailing delimiter. env[resolvePathEnvKey(env, platform)] = inheritedPath @@ -55,6 +59,7 @@ export function prependOrcaCliDirToChildPath( // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) if (shimDir) { + env.ORCA_CLI_BIN_DIR = shimDir const inheritedEntries = readInheritedPath(env, platform) .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) @@ -64,6 +69,9 @@ export function prependOrcaCliDirToChildPath( } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. const bundledCliBin = join(opts.resourcesPath, 'bin') + if (platform === 'darwin') { + env.ORCA_CLI_BIN_DIR = bundledCliBin + } const inheritedPath = readInheritedPath(env, platform) env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` diff --git a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts index 6ae5c7f4c63d..bf2e25451020 100644 --- a/src/main/daemon/daemon-bash-shell-ready-rcfile.ts +++ b/src/main/daemon/daemon-bash-shell-ready-rcfile.ts @@ -1,4 +1,5 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { getPosixCodexShellLaunchPreflight } from '../../shared/codex-shell-function' import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' @@ -35,6 +36,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 0fbdce87029c..71eee763fcc9 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import type * as DaemonBashRcfileModule from './daemon-bash-shell-ready-rcfile' +import { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready-bash-rcfile' +import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile' +import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { OVERLAY_ONLY_FEATURES, STARTUP_COMMAND_FEATURES @@ -57,6 +60,45 @@ describePosix('daemon shell-ready bash wrapper', () => { vi.restoreAllMocks() }) + itWithBash.each([ + ['daemon', getDaemonBashShellReadyRcfileContent], + ['local', getBashShellReadyRcfileContent] + ] as const)('keeps this app CLI first after %s Bash profiles reset PATH', (_lane, content) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + const env: Record = { + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin`, + ORCA_BACKGROUND_LAUNCH: '1' + } + const expectedLauncher = prependOrcaCliDirToChildPath(env, { + isPackaged: false, + userDataPath + }) + writeFileSync( + join(userDataPath, '.bash_profile'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const rcfile = join(userDataPath, 'cli-path-rcfile') + writeFileSync(rcfile, content()) + const result = spawnSync('bash', ['-c', '. "$1"; command -v orca-dev', 'bash', rcfile], { + env, + encoding: 'utf8', + timeout: 5000 + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout.replace(/\x1b\]133;C\x07/g, '').trim()).toBe(expectedLauncher) + }) + // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. it('emits OSC 133 C/D markers in the daemon bash wrapper', async () => { const { getShellReadyLaunchConfig } = await importFreshShellReady() diff --git a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts index d16715ed4966..169773edbd59 100644 --- a/src/main/providers/local-pty-shell-ready-bash-rcfile.ts +++ b/src/main/providers/local-pty-shell-ready-bash-rcfile.ts @@ -5,6 +5,7 @@ * startup-file chain, OSC 133 hooks, and the shell-ready marker all live here. */ import { BASH_PROMPT_COMMAND_COMPOSITION_BLOCK } from '../bash-prompt-command-composition' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../../shared/managed-data-account-shell' import { WSL_MANAGED_CLI_PATH_RESTORE } from '../wsl-managed-cli-path-restore' import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper' @@ -47,6 +48,7 @@ __orca_restore_agent_teams_path() { export PATH="\${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path +${ORCA_CLI_POSIX_PATH_RESTORE} ${WSL_MANAGED_CLI_PATH_RESTORE} # Why: user startup files may set the default OpenCode config after Orca's # spawn env; restore the Orca-managed config dir before the first prompt. diff --git a/src/main/providers/ssh-pty-spawn-env.test.ts b/src/main/providers/ssh-pty-spawn-env.test.ts new file mode 100644 index 000000000000..b1fba08b0ca4 --- /dev/null +++ b/src/main/providers/ssh-pty-spawn-env.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { buildSshPtySpawnEnv } from './ssh-pty-spawn-env' +import type { RemoteCliBridgeEnv } from './ssh-pty-provider-contract' + +const bridge: RemoteCliBridgeEnv = { + binDir: '/remote/orca/bin', + relayDir: '/remote/orca', + nodePath: '/remote/node', + sockPath: '/remote/orca/socket' +} + +describe('SSH CLI path ownership', () => { + it('replaces the client restore directory with the remote bridge', () => { + const env = { PATH: '/usr/bin', ORCA_CLI_BIN_DIR: '/client/orca/bin' } + const result = buildSshPtySpawnEnv({ env, remoteCliBridgeEnv: bridge }) + expect(result.ORCA_CLI_BIN_DIR).toBe(bridge.binDir) + expect(result.PATH).toBe(`${bridge.binDir}:/usr/bin`) + expect(env.ORCA_CLI_BIN_DIR).toBe('/client/orca/bin') + }) + + it('clears a client path when no remote bridge is available', () => { + const result = buildSshPtySpawnEnv({ env: { ORCA_CLI_BIN_DIR: '/client/orca/bin' } }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) + + it('does not give a POSIX wrapper a Windows bridge directory', () => { + const result = buildSshPtySpawnEnv({ + env: { Path: 'C:\\Windows', ORCA_CLI_BIN_DIR: '/client/orca/bin' }, + remoteCliBridgeEnv: { ...bridge, binDir: 'C:\\Orca\\bin', pathDelimiter: ';' } + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + expect(result.Path).toBe('C:\\Orca\\bin;C:\\Windows') + }) + + it('preserves an explicitly deleted restore key', () => { + const result = buildSshPtySpawnEnv({ + env: { PATH: '/usr/bin' }, + remoteCliBridgeEnv: bridge, + envToDelete: ['ORCA_CLI_BIN_DIR'] + }) + expect(result.ORCA_CLI_BIN_DIR).toBeUndefined() + }) +}) diff --git a/src/main/providers/ssh-pty-spawn-env.ts b/src/main/providers/ssh-pty-spawn-env.ts index 595e750c90d9..45e2abca8bdd 100644 --- a/src/main/providers/ssh-pty-spawn-env.ts +++ b/src/main/providers/ssh-pty-spawn-env.ts @@ -7,8 +7,13 @@ export function buildSshPtySpawnEnv(args: { remoteCliBridgeEnv?: RemoteCliBridgeEnv }): Record { const merged = { ...args.env } + // The client CLI path cannot be restored on the execution host. + delete merged.ORCA_CLI_BIN_DIR if (args.remoteCliBridgeEnv) { const pathDelimiter = args.remoteCliBridgeEnv.pathDelimiter ?? ':' + if (pathDelimiter === ':') { + merged.ORCA_CLI_BIN_DIR = args.remoteCliBridgeEnv.binDir + } const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index e21269bfa917..7351e75df5ea 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -17,6 +17,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' import { selectShellStartupFeatures } from './shell-startup-features' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' import { runZshPty } from './zsh-startup-hook-pty-harness' import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' import { @@ -119,6 +120,38 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) + itWithZsh('restores this app CLI after zsh startup files reset PATH', async () => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, ...PLAIN_PANE }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) + }) + it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts index 1a883e9f769d..3e7dc6343d88 100644 --- a/src/main/shell-startup-features.ts +++ b/src/main/shell-startup-features.ts @@ -32,7 +32,8 @@ const OVERLAY_ENV_KEYS = [ 'ORCA_OMP_STATUS_EXTENSION', 'ORCA_CODEX_HOME', 'ORCA_AGENT_TEAMS_SHIM_DIR', - 'ORCA_REMOTE_CLI_BIN_DIR' + 'ORCA_REMOTE_CLI_BIN_DIR', + 'ORCA_CLI_BIN_DIR' ] as const export type ShellStartupFeatureInput = { diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index d6d6ef39a30d..6a55468e1df1 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -1,3 +1,4 @@ +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-account-shell' /** * The single `.zshenv` Orca writes for every transport: local PTY, daemon/SSH, @@ -122,6 +123,7 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { return [ spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, + ORCA_CLI_POSIX_PATH_RESTORE, OPENCODE_CONFIG_DIR_RESTORE, MANAGED_DATA_ACCOUNT_POSIX_RESTORE, MIMOCODE_HOME_RESTORE, diff --git a/src/relay/pty-shell-overlay-wrappers.ts b/src/relay/pty-shell-overlay-wrappers.ts index c8d699f36bee..82ad3a7fb85c 100644 --- a/src/relay/pty-shell-overlay-wrappers.ts +++ b/src/relay/pty-shell-overlay-wrappers.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' +import { ORCA_CLI_POSIX_PATH_RESTORE } from '../shared/orca-cli-shell-path' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { BASH_FEATURE_CHANNEL_BLOCK, @@ -73,6 +74,7 @@ fi [[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}" [[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}" [[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac +${ORCA_CLI_POSIX_PATH_RESTORE} ${getPosixOmpShellWrapper()} ${getPosixCodexShellLaunchPreflight()}${BASH_HISTFILE_RESTORE_BLOCK} # Why: SSH bash sessions need the same command lifecycle markers as local diff --git a/src/shared/orca-cli-shell-path.ts b/src/shared/orca-cli-shell-path.ts new file mode 100644 index 000000000000..6bbe825a45e9 --- /dev/null +++ b/src/shared/orca-cli-shell-path.ts @@ -0,0 +1,6 @@ +export const ORCA_CLI_POSIX_PATH_RESTORE = `if [ -n "\${ORCA_CLI_BIN_DIR:-}" ]; then + case "\${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR\${PATH:+:$PATH}" ;; + esac +fi` From 2746e623fd6e4ee369719977c1b7235442eecfe1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 20:01:17 -0700 Subject: [PATCH 82/93] Use a literal marker for the Bash lookup regression --- src/main/daemon/shell-ready-bash-wrapper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/daemon/shell-ready-bash-wrapper.test.ts b/src/main/daemon/shell-ready-bash-wrapper.test.ts index 71eee763fcc9..059612f5a4be 100644 --- a/src/main/daemon/shell-ready-bash-wrapper.test.ts +++ b/src/main/daemon/shell-ready-bash-wrapper.test.ts @@ -96,7 +96,7 @@ describePosix('daemon shell-ready bash wrapper', () => { }) expect(result.error).toBeUndefined() expect(result.status).toBe(0) - expect(result.stdout.replace(/\x1b\]133;C\x07/g, '').trim()).toBe(expectedLauncher) + expect(result.stdout.split('\x1b]133;C\x07').join('').trim()).toBe(expectedLauncher) }) // Why: regression guard for issue #2422 — bash wrapper must emit OSC 133 C/D so SSH sessions clear stale 'working' agent rows. From 5a2479b7b6a994401e036327694f0989c7e0cb04 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:17:52 -0700 Subject: [PATCH 83/93] Preserve plain panes and initialize zsh after prompt hook replacement --- .../daemon-zsh-zshenv.txt | 39 ++++-- .../local-zsh-zshenv.txt | 39 ++++-- .../relay-zsh-zshenv.txt | 38 ++++-- .../ipc/pty-spawn-env-terminal-basics.test.ts | 36 ++++++ .../providers/ssh-pty-provider-spawn.test.ts | 2 + .../shell-startup-feature-channel.test.ts | 69 ++++++----- src/main/shell-startup-features.ts | 3 +- src/main/shell-templates.ts | 1 + ...erred-startup-line-init.live-shell.test.ts | 114 ++++++++++++++++++ src/main/zsh-deferred-startup-line-init.ts | 26 ++++ src/main/zsh-startup-wrapper-builder.ts | 14 ++- 11 files changed, 325 insertions(+), 56 deletions(-) create mode 100644 src/main/zsh-deferred-startup-line-init.live-shell.test.ts create mode 100644 src/main/zsh-deferred-startup-line-init.ts diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 51ede33c5298..1ab6ac5083b5 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -45,6 +45,21 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + zle zle-line-init "$@" + fi +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,12 +69,27 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -70,12 +100,6 @@ __orca_deferred_init() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path - if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then - case "${PATH:-}" in - "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; - *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; - esac - fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" @@ -208,7 +232,7 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" @@ -217,4 +241,5 @@ __orca_deferred_init() { builtin unset _orca_user_zshenv builtin typeset -ag precmd_functions (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 3d11da446627..0301d1631d1e 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -45,6 +45,21 @@ __orca_osc133_preexec() { # which prints a warning above every command under warn_create_global. builtin typeset -g __orca_in_command=1 } +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + zle zle-line-init "$@" + fi +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -54,8 +69,17 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi if __orca_has_feature markers; then precmd_functions=(${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( ${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]}) else precmd_functions=(${precmd_functions:#__orca_deferred_init}) @@ -67,6 +91,12 @@ __orca_deferred_init() { printf 'Orca CLI unavailable: cannot run %s. Check WSL Windows-drive mount options.\n' "$ORCA_WSL_CLI_DIR/${ORCA_CLI_COMMAND:-}" >&2 fi fi + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: ~/.zshrc can export the user's default OpenCode config after spawn. __orca_restore_agent_teams_path() { @@ -77,12 +107,6 @@ __orca_deferred_init() { export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH" } __orca_restore_agent_teams_path - if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then - case "${PATH:-}" in - "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; - *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; - esac - fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" @@ -225,7 +249,7 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" @@ -234,4 +258,5 @@ __orca_deferred_init() { builtin unset _orca_user_zshenv builtin typeset -ag precmd_functions (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index da5ae3cc2feb..5ff440e7b65f 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -31,6 +31,21 @@ builtin typeset -g _orca_histfile="${ORCA_HISTFILE:-}" builtin unset ORCA_HISTFILE __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" +__orca_deferred_line_init() { + builtin emulate -L zsh + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + zle zle-line-init "$@" + fi +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init +} __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise # inherit whatever options that config left set. Under NO_UNSET an unset @@ -40,15 +55,23 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if (( ${+widgets[__orca_saved_line_init]} )); then + if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi precmd_functions=(${precmd_functions:#__orca_deferred_init}) + if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then + case "${PATH:-}" in + "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; + *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; + esac + fi if __orca_has_feature overlay; then # Why: remote startup files can re-export user defaults after relay spawn. - if [ -n "${ORCA_CLI_BIN_DIR:-}" ]; then - case "${PATH:-}" in - "$ORCA_CLI_BIN_DIR"|"$ORCA_CLI_BIN_DIR":*) ;; - *) export PATH="$ORCA_CLI_BIN_DIR${PATH:+:$PATH}" ;; - esac - fi [[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}" if [[ -n "${ORCA_DATA_ACCOUNT_DATA_HOME:-}" ]]; then export XDG_DATA_HOME="${ORCA_DATA_ACCOUNT_DATA_HOME}" @@ -176,7 +199,7 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" @@ -185,4 +208,5 @@ __orca_deferred_init() { builtin unset _orca_user_zshenv builtin typeset -ag precmd_functions (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init } diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 224238bff9ab..d530a9cd1947 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -11,6 +11,7 @@ import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' import { registerPtyHandlers, buildPtyHostEnv, clearProviderPtyState } from './pty' import { buildJcodeRuntimeDir, shouldInjectJcodeRuntimeDir } from '../../shared/jcode-runtime-dir' import { makePaneKey } from '../../shared/stable-pane-id' +import { selectShellStartupFeatures } from '../shell-startup-features' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -60,6 +61,41 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it.each(['/bin/bash', '/bin/zsh'])( + 'does not wrap a bare %s pane merely to expose this app CLI', + (shellPath) => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + try { + const env = buildPtyHostEnv( + 'bare-cli-pane', + {}, + { + isPackaged: false, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false + } + ) + expect(env.ORCA_CLI_BIN_DIR).toBe('/tmp/orca-user-data/cli/bin') + expect( + selectShellStartupFeatures({ + shellPath, + env, + hasStartupCommand: false, + waitsForShellReady: false, + emitsStartupIdentity: false + }) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + } + ) + it('does not install managed Pi extensions when Pi is disabled', () => { piBuildPtyEnvMock.mockClear() diff --git a/src/main/providers/ssh-pty-provider-spawn.test.ts b/src/main/providers/ssh-pty-provider-spawn.test.ts index 06eb32d586f8..6626a238ce03 100644 --- a/src/main/providers/ssh-pty-provider-spawn.test.ts +++ b/src/main/providers/ssh-pty-provider-spawn.test.ts @@ -460,6 +460,7 @@ describe('spawn', () => { PATH: '/home/user/.orca-relay/bin:/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', @@ -490,6 +491,7 @@ describe('spawn', () => { env: { ORCA_TERMINAL_HANDLE: 'term_ssh', [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true', + ORCA_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin', ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1', ORCA_RELAY_NODE_PATH: '/usr/bin/node', diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 7351e75df5ea..7b144a264734 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -120,37 +120,46 @@ describePosix('zsh launch config', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - itWithZsh('restores this app CLI after zsh startup files reset PATH', async () => { - const cliBin = join(userDataPath, 'cli', 'bin') - const ambientBin = join(userDataPath, 'ambient-bin') - mkdirSync(cliBin, { recursive: true }) - mkdirSync(ambientBin) - for (const bin of [cliBin, ambientBin]) { - const launcher = join(bin, 'orca-dev') - writeFileSync(launcher, '#!/bin/sh\nexit 0\n') - chmodSync(launcher, 0o755) - } - writeFileSync( - join(userDataPath, '.zshrc'), - 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' - ) - const env: Record = { - ...process.env, - HOME: userDataPath, - USERPROFILE: userDataPath, - PATH: `${ambientBin}:/usr/bin:/bin` + itWithZsh.each([false, true])( + 'restores this app CLI after zsh startup resets PATH and replaces prompt hooks %s', + async (replacePromptHooks) => { + const cliBin = join(userDataPath, 'cli', 'bin') + const ambientBin = join(userDataPath, 'ambient-bin') + mkdirSync(cliBin, { recursive: true }) + mkdirSync(ambientBin) + for (const bin of [cliBin, ambientBin]) { + const launcher = join(bin, 'orca-dev') + writeFileSync(launcher, '#!/bin/sh\nexit 0\n') + chmodSync(launcher, 0o755) + } + writeFileSync( + join(userDataPath, '.zshrc'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + + (replacePromptHooks ? 'precmd_functions=()\n' : '') + ) + const env: Record = { + ...process.env, + HOME: userDataPath, + USERPROFILE: userDataPath, + PATH: `${ambientBin}:/usr/bin:/bin` + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + ...PLAIN_PANE, + hasStartupCommand: true + }) + const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() + const config = getShellLaunchConfig(ZSH_PATH, features) + const result = await runZshPty({ + env: { ...env, ...config.env }, + commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], + report: ['ORCA_LOOKUP'] + }) + expect(result.values.ORCA_LOOKUP).toBe(launcher) } - const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath }) - const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, ...PLAIN_PANE }) - const { getShellLaunchConfig } = await importFreshLocalPtyShellReady() - const config = getShellLaunchConfig(ZSH_PATH, features) - const result = await runZshPty({ - env: { ...env, ...config.env }, - commands: ['ORCA_LOOKUP=$(command -v orca-dev)'], - report: ['ORCA_LOOKUP'] - }) - expect(result.values.ORCA_LOOKUP).toBe(launcher) - }) + ) it('publishes exactly the selected features, whatever process.env holds', async () => { process.env.ORCA_SHELL_FEATURES = 'overlay,markers,ready,identity' diff --git a/src/main/shell-startup-features.ts b/src/main/shell-startup-features.ts index 3e7dc6343d88..1a883e9f769d 100644 --- a/src/main/shell-startup-features.ts +++ b/src/main/shell-startup-features.ts @@ -32,8 +32,7 @@ const OVERLAY_ENV_KEYS = [ 'ORCA_OMP_STATUS_EXTENSION', 'ORCA_CODEX_HOME', 'ORCA_AGENT_TEAMS_SHIM_DIR', - 'ORCA_REMOTE_CLI_BIN_DIR', - 'ORCA_CLI_BIN_DIR' + 'ORCA_REMOTE_CLI_BIN_DIR' ] as const export type ShellStartupFeatureInput = { diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index cb7c2fca2e98..b4eda05beb28 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -104,6 +104,7 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ builtin unset _orca_user_zshenv builtin typeset -ag precmd_functions (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts new file mode 100644 index 000000000000..ebbf40099bd1 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -0,0 +1,114 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { prependOrcaCliDirToChildPath } from './cli/orca-cli-child-path' +import { POSIX_SHELL_STARTUP_COMMAND_ENV } from './pty/posix-shell-startup-command' +import { getZshShellReadyWrapperFile } from './providers/local-pty-shell-ready-wrapper-generation' +import { encodeShellStartupFeatures, selectShellStartupFeatures } from './shell-startup-features' +import { ZSH_WRAPPER_DIR_MARKER_FILE } from './shell-templates' +import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-harness' + +const itWithZsh = hasZsh ? it : it.skip +const USER_WIDGET = `orca_test_line_init() { + ORCA_USER_WIDGET_CALLS=$((\${ORCA_USER_WIDGET_CALLS:-0}+1)) + ORCA_USER_WIDGET_NAME="$WIDGET" + return 1 +} +zle -N zle-line-init orca_test_line_init +` + +describe('zsh deferred startup after prompt-hook replacement', () => { + const roots: string[] = [] + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + itWithZsh.each(['history', 'startup'] as const)( + 'restores CLI precedence and preserves the user widget in a %s pane', + async (intent) => { + const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) + roots.push(home) + const cliBin = join(home, 'cli', 'bin') + const ambientBin = join(home, 'ambient-bin') + const wrapperDir = join(home, 'wrapper') + for (const bin of [cliBin, ambientBin, wrapperDir]) { + mkdirSync(bin, { recursive: true }) + } + for (const bin of [cliBin, ambientBin]) { + writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') + chmodSync(join(bin, 'orca-dev'), 0o755) + } + writeFileSync(join(home, '.zshenv'), USER_WIDGET) + writeFileSync( + join(home, '.zshrc'), + 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\nprecmd_functions=()\n' + ) + writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) + writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') + const env: Record = { + ...process.env, + HOME: home, + USERPROFILE: home, + PATH: `${ambientBin}:/usr/bin:/bin`, + ZDOTDIR: wrapperDir, + ORCA_HISTFILE: join(home, 'scoped-history') + } + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: false, userDataPath: home }) + const features = selectShellStartupFeatures({ + shellPath: ZSH_PATH, + env, + hasStartupCommand: intent === 'startup', + waitsForShellReady: intent === 'startup', + emitsStartupIdentity: false + }) + env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) + if (intent === 'startup') { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'ORCA_STARTUP_RUNS=$((${ORCA_STARTUP_RUNS:-0}+1))' + } + + const result = await runZshPty({ + env, + commands: [ + 'ORCA_LOOKUP=$(command -v orca-dev)', + 'ORCA_INIT_REMAINS=$+functions[__orca_deferred_line_init]', + 'ORCA_SAVED_WIDGET_REMAINS=$+widgets[__orca_saved_line_init]', + 'ORCA_LINE_INIT=${widgets[zle-line-init]:-none}', + 'ORCA_PRECMD="${precmd_functions[*]}"' + ], + report: [ + 'ORCA_LOOKUP', + 'ORCA_USER_WIDGET_CALLS', + 'ORCA_USER_WIDGET_NAME', + 'ORCA_INIT_REMAINS', + 'ORCA_SAVED_WIDGET_REMAINS', + 'ORCA_LINE_INIT', + 'ORCA_PRECMD', + 'ORCA_STARTUP_RUNS', + 'HISTFILE' + ] + }) + + expect(result.values.ORCA_LOOKUP).toBe(launcher) + expect(Number(result.values.ORCA_USER_WIDGET_CALLS)).toBeGreaterThan(0) + expect(result.values.ORCA_USER_WIDGET_NAME).toBe('zle-line-init') + expect(result.values.ORCA_INIT_REMAINS).toBe('0') + expect(result.values.ORCA_SAVED_WIDGET_REMAINS).toBe('0') + expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) + if (intent === 'history') { + expect(result.output).not.toContain('\x1b]133;') + expect(result.values.ORCA_LINE_INIT).toBe('user:orca_test_line_init') + expect(result.values.ORCA_PRECMD).not.toContain('orca') + expect(result.values.ORCA_STARTUP_RUNS).toBe('UNSET') + } else { + expect(result.output).toContain(MARKERS.ready) + expect(result.values.ORCA_LINE_INIT).toBe('user:__orca_prompt_mark') + expect(result.values.ORCA_PRECMD).toBe('__orca_osc133_precmd') + expect(result.values.ORCA_STARTUP_RUNS).toBe('1') + } + } + ) +}) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts new file mode 100644 index 000000000000..ebabe0e7c812 --- /dev/null +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -0,0 +1,26 @@ +// Why: user startup files can replace precmd_functions after Orca registers its hook. +export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { + builtin emulate -L zsh + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + if (( \${+widgets[zle-line-init]} )) && [[ "\${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + zle zle-line-init "$@" + fi +} +__orca_arm_deferred_line_init() { + builtin emulate -L zsh + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init +}` + +// Why: restore the exact prior widget before the existing readiness hook captures it. +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then + if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -A __orca_saved_line_init zle-line-init + fi + zle -D __orca_saved_line_init + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then + zle -D zle-line-init + fi` diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index 6a55468e1df1..ca90543746d7 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -15,7 +15,8 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou * dir shared by two installed builds could mix files from both. * * This shape gives ZDOTDIR back before anything else can observe it, then defers - * Orca's work to a `precmd` hook that runs at the first prompt — after + * Orca's work to a `precmd` hook, with a one-shot line-editor fallback if the + * user's config replaces its hook array. Both run at the first prompt — after * `.zprofile`, `/etc/zshrc`, `.zshrc` and `.zlogin`, all of which zsh now reads * from the user's own directory exactly as in an unwrapped shell. #11044 becomes * unreachable rather than repaired, and the emulation and mixed-build classes @@ -31,6 +32,10 @@ import { MANAGED_DATA_ACCOUNT_POSIX_RESTORE } from '../shared/managed-data-accou import { getPosixOmpShellWrapper } from './pty/omp-shell-wrapper' import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' +import { + ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK +} from './zsh-deferred-startup-line-init' import { getZshShellReadyMarkerRegistrationBlock, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, @@ -123,7 +128,6 @@ function getOverlayRestoreBlocks(spec: ZshStartupHookSpec): (string | null)[] { return [ spec.overlayRestoreComment, spec.restores.agentTeamsPath ? AGENT_TEAMS_PATH_RESTORE_BLOCK : null, - ORCA_CLI_POSIX_PATH_RESTORE, OPENCODE_CONFIG_DIR_RESTORE, MANAGED_DATA_ACCOUNT_POSIX_RESTORE, MIMOCODE_HOME_RESTORE, @@ -148,6 +152,7 @@ function buildDeferredInit(spec: ZshStartupHookSpec): string { const permanentPrecmd = spec.osc133CommandMarkers ? ` if __orca_has_feature markers; then precmd_functions=(\${precmd_functions:/__orca_deferred_init/__orca_osc133_precmd}) + (( \${precmd_functions[(Ie)__orca_osc133_precmd]} )) || precmd_functions+=(__orca_osc133_precmd) preexec_functions=(__orca_osc133_preexec \${preexec_functions[@]}) else precmd_functions=(\${precmd_functions:#__orca_deferred_init}) @@ -172,9 +177,11 @@ ${indentBlock(getZshShellReadyMarkerRegistrationBlock(spec.readyMarkerEscaped, t (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions +${ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK} ${permanentPrecmd} ${joinBlocks([ spec.restores.managedWslCli ? indentBlock(WSL_MANAGED_CLI_PATH_RESTORE, ' ') : null, + indentBlock(ORCA_CLI_POSIX_PATH_RESTORE, ' ').replace(/\n$/, ''), featureGuard('overlay', getOverlayRestoreBlocks(spec)), // Why outside the overlay guard: a system-default Codex home carries no overlay key. indentBlock(getPosixCodexShellLaunchPreflight(), ' ').replace(/\n$/, ''), @@ -192,7 +199,7 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature + builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init }` } @@ -203,6 +210,7 @@ export function buildZshStartupHook(spec: ZshStartupHookSpec): string { ZSH_FEATURE_CHANNEL_BLOCK, SHELL_STARTUP_IDENTITY_MARKER_BLOCK, spec.osc133CommandMarkers ? ZSH_OSC133_FUNCTION_BLOCK : null, + ZSH_DEFERRED_LINE_INIT_BLOCK, buildDeferredInit(spec), ZSH_USER_ZSHENV_SOURCE_BLOCK ])}\n` From c6bc30f831f280c7b018aaad98fcf7b29d00877d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:05 -0700 Subject: [PATCH 84/93] Preserve user line-editor dispatchers during deferred startup --- .../daemon-zsh-zshenv.txt | 20 +++++++++++--- .../local-zsh-zshenv.txt | 20 +++++++++++--- .../relay-zsh-zshenv.txt | 20 +++++++++++--- .../shell-startup-feature-channel.test.ts | 3 +-- ...erred-startup-line-init.live-shell.test.ts | 26 +++++++++++++------ src/main/zsh-deferred-startup-line-init.ts | 20 ++++++++++++-- .../zsh-scoped-histfile.live-shell.test.ts | 15 ++--------- src/main/zsh-startup-wrapper-builder.ts | 4 ++- 8 files changed, 93 insertions(+), 35 deletions(-) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 1ab6ac5083b5..79538ad53e30 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -47,9 +47,15 @@ __orca_osc133_preexec() { } __orca_deferred_line_init() { builtin emulate -L zsh - (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init - if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" fi } __orca_arm_deferred_line_init() { @@ -232,7 +238,15 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 0301d1631d1e..4f791ecd82f3 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -47,9 +47,15 @@ __orca_osc133_preexec() { } __orca_deferred_line_init() { builtin emulate -L zsh - (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init - if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" fi } __orca_arm_deferred_line_init() { @@ -249,7 +255,15 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 5ff440e7b65f..9170a63e6592 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -33,9 +33,15 @@ __orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) } __orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$" __orca_deferred_line_init() { builtin emulate -L zsh - (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init - if (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + (( ${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && ${+widgets[zle-line-init]} )); then zle zle-line-init "$@" + elif [[ "${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" fi } __orca_arm_deferred_line_init() { @@ -199,7 +205,15 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init + local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init } { builtin typeset _orca_user_zshenv="${ZDOTDIR-$HOME}/.zshenv" diff --git a/src/main/shell-startup-feature-channel.test.ts b/src/main/shell-startup-feature-channel.test.ts index 7b144a264734..0001a98641e5 100644 --- a/src/main/shell-startup-feature-channel.test.ts +++ b/src/main/shell-startup-feature-channel.test.ts @@ -134,8 +134,7 @@ describePosix('zsh launch config', () => { } writeFileSync( join(userDataPath, '.zshrc'), - 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n' + - (replacePromptHooks ? 'precmd_functions=()\n' : '') + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${replacePromptHooks ? 'precmd_functions=()\n' : ''}` ) const env: Record = { ...process.env, diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index ebbf40099bd1..9911449ed13a 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -13,6 +13,7 @@ const itWithZsh = hasZsh ? it : it.skip const USER_WIDGET = `orca_test_line_init() { ORCA_USER_WIDGET_CALLS=$((\${ORCA_USER_WIDGET_CALLS:-0}+1)) ORCA_USER_WIDGET_NAME="$WIDGET" + builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' return 1 } zle -N zle-line-init orca_test_line_init @@ -27,7 +28,7 @@ describe('zsh deferred startup after prompt-hook replacement', () => { } }) - itWithZsh.each(['history', 'startup'] as const)( + itWithZsh.each(['history', 'startup', 'chained-startup'] as const)( 'restores CLI precedence and preserves the user widget in a %s pane', async (intent) => { const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) @@ -42,10 +43,14 @@ describe('zsh deferred startup after prompt-hook replacement', () => { writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') chmodSync(join(bin, 'orca-dev'), 0o755) } - writeFileSync(join(home, '.zshenv'), USER_WIDGET) + writeFileSync(join(home, '.zshenv'), intent === 'chained-startup' ? '' : USER_WIDGET) writeFileSync( join(home, '.zshrc'), - 'export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\nprecmd_functions=()\n' + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\nprecmd_functions=()\n${ + intent === 'chained-startup' + ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` + : '' + }` ) writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') @@ -61,12 +66,12 @@ describe('zsh deferred startup after prompt-hook replacement', () => { const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, - hasStartupCommand: intent === 'startup', - waitsForShellReady: intent === 'startup', + hasStartupCommand: intent !== 'history', + waitsForShellReady: intent !== 'history', emitsStartupIdentity: false }) env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) - if (intent === 'startup') { + if (intent !== 'history') { env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'ORCA_STARTUP_RUNS=$((${ORCA_STARTUP_RUNS:-0}+1))' } @@ -94,8 +99,10 @@ describe('zsh deferred startup after prompt-hook replacement', () => { expect(result.values.ORCA_LOOKUP).toBe(launcher) expect(Number(result.values.ORCA_USER_WIDGET_CALLS)).toBeGreaterThan(0) - expect(result.values.ORCA_USER_WIDGET_NAME).toBe('zle-line-init') - expect(result.values.ORCA_INIT_REMAINS).toBe('0') + if (intent !== 'chained-startup') { + expect(result.values.ORCA_USER_WIDGET_NAME).toBe('zle-line-init') + expect(result.values.ORCA_INIT_REMAINS).toBe('0') + } expect(result.values.ORCA_SAVED_WIDGET_REMAINS).toBe('0') expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) if (intent === 'history') { @@ -108,6 +115,9 @@ describe('zsh deferred startup after prompt-hook replacement', () => { expect(result.values.ORCA_LINE_INIT).toBe('user:__orca_prompt_mark') expect(result.values.ORCA_PRECMD).toBe('__orca_osc133_precmd') expect(result.values.ORCA_STARTUP_RUNS).toBe('1') + expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( + result.output.split(MARKERS.ready).length - 1 + ) } } ) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts index ebabe0e7c812..917b4adbd094 100644 --- a/src/main/zsh-deferred-startup-line-init.ts +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -1,9 +1,15 @@ // Why: user startup files can replace precmd_functions after Orca registers its hook. export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { builtin emulate -L zsh - (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init - if (( \${+widgets[zle-line-init]} )) && [[ "\${widgets[zle-line-init]}" != user:__orca_deferred_line_init ]]; then + (( \${+functions[__orca_deferred_init]} )) || return 0 + local __orca_direct_line_init=0 + [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && __orca_direct_line_init=1 + __orca_deferred_init + if (( __orca_direct_line_init && \${+widgets[zle-line-init]} )); then zle zle-line-init "$@" + elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_prompt_mark ]]; then + local __orca_prev_line_init_fn="" + __orca_prompt_mark "$@" fi } __orca_arm_deferred_line_init() { @@ -24,3 +30,13 @@ export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_s elif [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -D zle-line-init fi` + +// Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` local __orca_widget __orca_line_init_bound=0 + for __orca_widget in "\${(v)widgets[@]}"; do + if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then + __orca_line_init_bound=1 + break + fi + done + (( __orca_line_init_bound )) || builtin unfunction __orca_deferred_line_init` diff --git a/src/main/zsh-scoped-histfile.live-shell.test.ts b/src/main/zsh-scoped-histfile.live-shell.test.ts index 68ee27d24e82..5c5020c96bce 100644 --- a/src/main/zsh-scoped-histfile.live-shell.test.ts +++ b/src/main/zsh-scoped-histfile.live-shell.test.ts @@ -333,25 +333,14 @@ describe.skipIf(process.platform === 'win32')( }) itWithZsh( - 'degrades to an unwrapped pane, leaking nothing, when a config drops precmd_functions', + 'still scopes history without leaking it when a config drops precmd_functions', withHome({ ...USER_FILES, '.zshrc': 'precmd_functions=()\n' }, async (home) => { const scoped = join(home, 'orca-history', 'zsh_history') const { env, launch } = launchPane(home, scoped) const report = ['HISTFILE', 'ORCA_HISTFILE', 'ZDOTDIR'] const { values } = await runZshPty({ env, report }) - // Why compared against an unwrapped run rather than asserted to differ - // from the scoped path: whether the scoped value survives at all is the - // host's call, not Orca's. macOS /etc/zshrc overwrites HISTFILE, so it - // does not; a host with no such assignment keeps whatever the spawn env - // set. The contract on both is the same — this pane is the pane the user - // would have had unwrapped. - const unwrapped = await runZshPty({ - env: { PATH: '/usr/bin:/bin', HOME: home, HISTFILE: scoped }, - report - }) - - expect(values.HISTFILE).toBe(unwrapped.values.HISTFILE) + expect(values.HISTFILE).toBe(scoped) expect(values.HISTFILE).not.toContain(launch.env.ZDOTDIR) // ORCA_HISTFILE was consumed in .zshenv precisely so a dropped hook // leaks nothing to the pane's children. diff --git a/src/main/zsh-startup-wrapper-builder.ts b/src/main/zsh-startup-wrapper-builder.ts index ca90543746d7..6ec28b647c2b 100644 --- a/src/main/zsh-startup-wrapper-builder.ts +++ b/src/main/zsh-startup-wrapper-builder.ts @@ -34,6 +34,7 @@ import { WSL_MANAGED_CLI_PATH_RESTORE } from './wsl-managed-cli-path-restore' import { getPosixCodexShellLaunchPreflight } from '../shared/codex-shell-function' import { ZSH_DEFERRED_LINE_INIT_BLOCK, + ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK, ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK } from './zsh-deferred-startup-line-init' import { @@ -199,7 +200,8 @@ ${ __orca_has_feature markers && __orca_osc133_precmd\n` : '' } builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_init __orca_has_feature __orca_deferred_line_init __orca_arm_deferred_line_init +${ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK} + builtin unfunction __orca_deferred_init __orca_has_feature __orca_arm_deferred_line_init }` } From f4bd404827366866095b76441eabca88ddb5f9b4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:50:25 -0700 Subject: [PATCH 85/93] fix: retain CLI startup when global Zsh replaces prompt hooks --- .github/workflows/pr.yml | 1 + config/scripts/ci-unit-files.mjs | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 1 + .../daemon-zsh-zshenv.txt | 45 ++++- .../local-zsh-zshenv.txt | 45 ++++- .../relay-zsh-zshenv.txt | 45 ++++- ...codex-structured-child-environment.test.ts | 15 +- .../codex-structured-session-adapter.test.ts | 3 + ...uctured-session-child-identity-env.test.ts | 2 + ...erred-startup-line-init.live-shell.test.ts | 162 ++++++++++++++---- src/main/zsh-deferred-startup-line-init.ts | 51 +++++- 11 files changed, 313 insertions(+), 58 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9558255e482e..86bb40664257 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -680,6 +680,7 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/main/fish-xdg-data-dirs-handoff.test.ts \ src/main/shell-startup-feature-channel.test.ts \ + src/main/zsh-deferred-startup-line-init.live-shell.test.ts \ src/main/terminal-history-fish-session.node-pty.test.ts \ src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index 80252973e224..3e0cdb67cd8f 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -23,6 +23,7 @@ export const UNIT_EXCLUDE = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/terminal-history-fish-session.node-pty.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 91156e59bf0b..93a9c3820109 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -27,6 +27,7 @@ const shellContractFiles = [ 'src/main/pty/omp-shell-wrapper-alias-safety.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts', 'src/main/shell-startup-feature-channel.test.ts', + 'src/main/zsh-deferred-startup-line-init.live-shell.test.ts', 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 79538ad53e30..ae1592f6d7bc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -58,13 +58,41 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } +__orca_deferred_precmd() { + local __orca_precmd_status=0 + if (( ${+functions[__orca_saved_precmd]} )); then + local __orca_original_precmd="${functions[__orca_saved_precmd]}" + functions[precmd]="$__orca_original_precmd" + precmd "$@" + __orca_precmd_status=$? + if (( ${+functions[__orca_deferred_init]} )) && + [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then + functions[precmd]="${functions[__orca_deferred_precmd]}" + fi + fi + __orca_deferred_precmd_fallback + return $__orca_precmd_status +} +__orca_deferred_precmd_fallback() { + builtin emulate -L zsh + if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then + __orca_deferred_init + fi +} __orca_arm_deferred_line_init() { builtin emulate -L zsh - [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 - if (( ${+widgets[zle-line-init]} )); then - zle -A zle-line-init __orca_saved_line_init + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[precmd]} )); then + functions[__orca_saved_precmd]="${functions[precmd]}" + fi + functions[precmd]="${functions[__orca_deferred_precmd]}" fi - zle -N zle-line-init __orca_deferred_line_init } __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise @@ -75,6 +103,13 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[__orca_saved_precmd]} )); then + functions[precmd]="${functions[__orca_saved_precmd]}" + else + builtin unfunction precmd + fi + fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -238,6 +273,8 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback + (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 4f791ecd82f3..6561bf5cbf89 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -58,13 +58,41 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } +__orca_deferred_precmd() { + local __orca_precmd_status=0 + if (( ${+functions[__orca_saved_precmd]} )); then + local __orca_original_precmd="${functions[__orca_saved_precmd]}" + functions[precmd]="$__orca_original_precmd" + precmd "$@" + __orca_precmd_status=$? + if (( ${+functions[__orca_deferred_init]} )) && + [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then + functions[precmd]="${functions[__orca_deferred_precmd]}" + fi + fi + __orca_deferred_precmd_fallback + return $__orca_precmd_status +} +__orca_deferred_precmd_fallback() { + builtin emulate -L zsh + if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then + __orca_deferred_init + fi +} __orca_arm_deferred_line_init() { builtin emulate -L zsh - [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 - if (( ${+widgets[zle-line-init]} )); then - zle -A zle-line-init __orca_saved_line_init + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[precmd]} )); then + functions[__orca_saved_precmd]="${functions[precmd]}" + fi + functions[precmd]="${functions[__orca_deferred_precmd]}" fi - zle -N zle-line-init __orca_deferred_line_init } __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise @@ -75,6 +103,13 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[__orca_saved_precmd]} )); then + functions[precmd]="${functions[__orca_saved_precmd]}" + else + builtin unfunction precmd + fi + fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -255,6 +290,8 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback + (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 9170a63e6592..eab61394321c 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -44,13 +44,41 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } +__orca_deferred_precmd() { + local __orca_precmd_status=0 + if (( ${+functions[__orca_saved_precmd]} )); then + local __orca_original_precmd="${functions[__orca_saved_precmd]}" + functions[precmd]="$__orca_original_precmd" + precmd "$@" + __orca_precmd_status=$? + if (( ${+functions[__orca_deferred_init]} )) && + [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then + functions[precmd]="${functions[__orca_deferred_precmd]}" + fi + fi + __orca_deferred_precmd_fallback + return $__orca_precmd_status +} +__orca_deferred_precmd_fallback() { + builtin emulate -L zsh + if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then + __orca_deferred_init + fi +} __orca_arm_deferred_line_init() { builtin emulate -L zsh - [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 - if (( ${+widgets[zle-line-init]} )); then - zle -A zle-line-init __orca_saved_line_init + if [[ "${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( ${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[precmd]} )); then + functions[__orca_saved_precmd]="${functions[precmd]}" + fi + functions[precmd]="${functions[__orca_deferred_precmd]}" fi - zle -N zle-line-init __orca_deferred_line_init } __orca_deferred_init() { # Why first: this body runs after the user's own config, so it would otherwise @@ -61,6 +89,13 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions + if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then + if (( ${+functions[__orca_saved_precmd]} )); then + functions[precmd]="${functions[__orca_saved_precmd]}" + else + builtin unfunction precmd + fi + fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -205,6 +240,8 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile + builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback + (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 4dcd49e96459..517f9effb90c 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,7 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +const DEV_CLI_BIN_DIR = /^[^:;]*[\\/]cli[\\/]bin$/ // The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ @@ -23,7 +24,11 @@ describe('buildCodexStructuredChildEnvironment', () => { cwd: '/worktree', codexHome: '/pinned/home', resumeThreadId: null, - env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + env: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home', + ORCA_CLI_BIN_DIR: '/inherited/unowned-cli' + } }, 'spawn-token', 'session-not-a-worker' @@ -35,6 +40,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -57,6 +65,9 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(DEV_CLI_BIN_DIR) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -97,6 +108,7 @@ const ENV_REPORTING_APP_SERVER = String.raw` result: { sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + cliBinDir: process.env.ORCA_CLI_BIN_DIR ?? null, path: process.env.PATH ?? process.env.Path ?? null } }) @@ -135,6 +147,7 @@ describe('the spawned Codex child', () => { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), + cliBinDir: process.platform === 'win32' ? null : expect.stringMatching(DEV_CLI_BIN_DIR), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 70b2ecd7147a..9276d670b4ce 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -38,6 +38,9 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ...(process.platform !== 'win32' + ? { ORCA_CLI_BIN_DIR: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin$/) } + : {}), ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 1289e979ad63..095aad2a8a5c 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,6 +70,7 @@ describe('structuredSessionChildIdentityEnv', () => { // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + ORCA_CLI_BIN_DIR: SHIM_DIR, // The instance that minted the id, so any current CLI dials it rather than the default. ORCA_USER_DATA_PATH: USER_DATA }) @@ -131,6 +132,7 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env.PATH).toBeUndefined() // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) + expect(env.ORCA_CLI_BIN_DIR).toBeUndefined() }) it('unpackaged, through the dev launcher dir', () => { diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index 9911449ed13a..23234cb95987 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -11,13 +11,27 @@ import { hasZsh, MARKERS, runZshPty, ZSH_PATH } from './zsh-startup-hook-pty-har const itWithZsh = hasZsh ? it : it.skip const USER_WIDGET = `orca_test_line_init() { - ORCA_USER_WIDGET_CALLS=$((\${ORCA_USER_WIDGET_CALLS:-0}+1)) - ORCA_USER_WIDGET_NAME="$WIDGET" + O_UC=$((\${O_UC:-0}+1)) + O_UN="$WIDGET" builtin printf 'ORCA_TEST_USER_WIDGET_CALL\\n' return 1 } zle -N zle-line-init orca_test_line_init ` +const USER_REDRAW = `orca_test_redraw() { + O_RC=$((\${O_RC:-0}+1)) + O_RN="$WIDGET" + return 1 +} +zle -N zle-line-pre-redraw orca_test_redraw +` +const USER_PRECMD = `precmd() { + O_PCALLS=$((\${O_PCALLS:-0}+1)) + O_PN="$0" + O_PO="\${O_PO:-\${options[ksharrays]}:\${options[nounset]}}" + return 1 +} +` describe('zsh deferred startup after prompt-hook replacement', () => { const roots: string[] = [] @@ -28,9 +42,26 @@ describe('zsh deferred startup after prompt-hook replacement', () => { } }) - itWithZsh.each(['history', 'startup', 'chained-startup'] as const)( + itWithZsh.each([ + 'history', + 'startup', + 'chained-startup', + 'stock-history', + 'stock-startup', + 'stock-chained-redraw', + 'stock-nonzero-precmd', + 'ordered-startup', + 'replaced-precmd-startup' + ] as const)( 'restores CLI precedence and preserves the user widget in a %s pane', async (intent) => { + const historyOnly = intent.endsWith('history') + const stockBinding = intent.startsWith('stock-') + const chainedLineInit = intent === 'chained-startup' + const chainedRedraw = intent === 'stock-chained-redraw' + const nonzeroPrecmd = intent === 'stock-nonzero-precmd' + const orderedPrecmd = intent === 'ordered-startup' + const replacedPrecmd = intent === 'replaced-precmd-startup' const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) roots.push(home) const cliBin = join(home, 'cli', 'bin') @@ -43,14 +74,31 @@ describe('zsh deferred startup after prompt-hook replacement', () => { writeFileSync(join(bin, 'orca-dev'), '#!/bin/sh\nexit 0\n') chmodSync(join(bin, 'orca-dev'), 0o755) } - writeFileSync(join(home, '.zshenv'), intent === 'chained-startup' ? '' : USER_WIDGET) + writeFileSync( + join(home, '.zshenv'), + (chainedLineInit || chainedRedraw ? '' : stockBinding ? USER_REDRAW : USER_WIDGET) + + (nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') + ) + if (stockBinding) { + // Ubuntu's global zshrc rebinds this widget between .zshenv and the user's .zshrc. + writeFileSync( + join(home, '.zprofile'), + USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + ) + } writeFileSync( join(home, '.zshrc'), - `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\nprecmd_functions=()\n${ - intent === 'chained-startup' + `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + chainedLineInit ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` - : '' - }` + : chainedRedraw + ? `${USER_REDRAW.replace('zle -N zle-line-pre-redraw orca_test_redraw', 'zle -N orca_test_redraw')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-pre-redraw orca_test_redraw\n` + : '' + }${nonzeroPrecmd ? 'orca_test_array() { O_AC=called; }\nprecmd_functions=(orca_test_array)\nsetopt KSH_ARRAYS NO_UNSET\n' : orderedPrecmd ? 'orca_test_array() { O_AO=${O_AO:-${_orca_deferred_init_done:-0}}; }\nprecmd_functions=(orca_test_array "${precmd_functions[@]}")\n' : replacedPrecmd ? 'precmd() { O_NPC=$((${O_NPC:-0}+1)); O_NPN="$0"; }\n' : ''}` ) writeFileSync(join(wrapperDir, '.zshenv'), getZshShellReadyWrapperFile()) writeFileSync(join(wrapperDir, ZSH_WRAPPER_DIR_MARKER_FILE), '') @@ -66,55 +114,93 @@ describe('zsh deferred startup after prompt-hook replacement', () => { const features = selectShellStartupFeatures({ shellPath: ZSH_PATH, env, - hasStartupCommand: intent !== 'history', - waitsForShellReady: intent !== 'history', + hasStartupCommand: !historyOnly, + waitsForShellReady: !historyOnly, emitsStartupIdentity: false }) env.ORCA_SHELL_FEATURES = encodeShellStartupFeatures(features) - if (intent !== 'history') { - env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'ORCA_STARTUP_RUNS=$((${ORCA_STARTUP_RUNS:-0}+1))' + if (!historyOnly) { + env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' } const result = await runZshPty({ env, commands: [ - 'ORCA_LOOKUP=$(command -v orca-dev)', - 'ORCA_INIT_REMAINS=$+functions[__orca_deferred_line_init]', - 'ORCA_SAVED_WIDGET_REMAINS=$+widgets[__orca_saved_line_init]', - 'ORCA_LINE_INIT=${widgets[zle-line-init]:-none}', - 'ORCA_PRECMD="${precmd_functions[*]}"' + 'O_LK=$(command -v orca-dev)', + 'O_IR=${+functions[__orca_deferred_line_init]}', + 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), + 'O_LI=${widgets[zle-line-init]:-none}', + 'O_PC="${precmd_functions[*]}"', + ...(nonzeroPrecmd ? ['precmd; O_PS=$?'] : []) ], report: [ - 'ORCA_LOOKUP', - 'ORCA_USER_WIDGET_CALLS', - 'ORCA_USER_WIDGET_NAME', - 'ORCA_INIT_REMAINS', - 'ORCA_SAVED_WIDGET_REMAINS', - 'ORCA_LINE_INIT', - 'ORCA_PRECMD', - 'ORCA_STARTUP_RUNS', + 'O_LK', + 'O_UC', + 'O_UN', + 'O_IR', + 'O_SR', + ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), + ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), + ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), + ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + 'O_LI', + 'O_PC', + 'O_SU', 'HISTFILE' ] }) - expect(result.values.ORCA_LOOKUP).toBe(launcher) - expect(Number(result.values.ORCA_USER_WIDGET_CALLS)).toBeGreaterThan(0) - if (intent !== 'chained-startup') { - expect(result.values.ORCA_USER_WIDGET_NAME).toBe('zle-line-init') - expect(result.values.ORCA_INIT_REMAINS).toBe('0') + expect(result.values.O_LK).toBe(launcher) + expect(Number(result.values.O_UC)).toBeGreaterThan(0) + if (!chainedLineInit) { + expect(result.values.O_UN).toBe('zle-line-init') + } + if (!chainedLineInit && !chainedRedraw) { + expect(result.values.O_IR).toBe('0') + } + expect(result.values.O_SR).toBe('0') + if (stockBinding) { + expect(Number(result.values.O_RC)).toBeGreaterThan(0) + expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') + if (!chainedRedraw) { + expect(result.values.O_RW).toBe('user:orca_test_redraw') + } } - expect(result.values.ORCA_SAVED_WIDGET_REMAINS).toBe('0') expect(result.values.HISTFILE).toBe(join(home, 'scoped-history')) - if (intent === 'history') { + if (nonzeroPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_PO).toBe('on:on') + expect(result.values.O_AC).toBe('called') + expect(result.values.O_PS).toBe('1') + } + if (orderedPrecmd) { + expect(Number(result.values.O_PCALLS)).toBeGreaterThan(0) + expect(result.values.O_PN).toBe('precmd') + expect(result.values.O_AO).toBe('0') + } + if (replacedPrecmd) { + expect(result.values.O_PCALLS).toBe('UNSET') + expect(Number(result.values.O_NPC)).toBeGreaterThan(0) + expect(result.values.O_NPN).toBe('precmd') + } + if (historyOnly) { expect(result.output).not.toContain('\x1b]133;') - expect(result.values.ORCA_LINE_INIT).toBe('user:orca_test_line_init') - expect(result.values.ORCA_PRECMD).not.toContain('orca') - expect(result.values.ORCA_STARTUP_RUNS).toBe('UNSET') + expect(result.values.O_LI).toBe( + stockBinding ? 'user:zle-line-init' : 'user:orca_test_line_init' + ) + expect(result.values.O_PC).not.toContain('orca') + expect(result.values.O_SU).toBe('UNSET') } else { expect(result.output).toContain(MARKERS.ready) - expect(result.values.ORCA_LINE_INIT).toBe('user:__orca_prompt_mark') - expect(result.values.ORCA_PRECMD).toBe('__orca_osc133_precmd') - expect(result.values.ORCA_STARTUP_RUNS).toBe('1') + expect(result.values.O_LI).toBe('user:__orca_prompt_mark') + expect(result.values.O_PC).toBe( + nonzeroPrecmd || orderedPrecmd + ? 'orca_test_array __orca_osc133_precmd' + : '__orca_osc133_precmd' + ) + expect(result.values.O_SU).toBe('1') expect(result.output.split('ORCA_TEST_USER_WIDGET_CALL\r\n').length - 1).toBe( result.output.split(MARKERS.ready).length - 1 ) diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts index 917b4adbd094..d3d76f01064a 100644 --- a/src/main/zsh-deferred-startup-line-init.ts +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -1,4 +1,4 @@ -// Why: user startup files can replace precmd_functions after Orca registers its hook. +// Why: user startup files can replace the prompt array and global zshrc can rebind line-init. export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { builtin emulate -L zsh (( \${+functions[__orca_deferred_init]} )) || return 0 @@ -12,17 +12,52 @@ export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { __orca_prompt_mark "$@" fi } +__orca_deferred_precmd() { + local __orca_precmd_status=0 + if (( \${+functions[__orca_saved_precmd]} )); then + local __orca_original_precmd="\${functions[__orca_saved_precmd]}" + functions[precmd]="$__orca_original_precmd" + precmd "$@" + __orca_precmd_status=$? + if (( \${+functions[__orca_deferred_init]} )) && + [[ "\${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then + functions[precmd]="\${functions[__orca_deferred_precmd]}" + fi + fi + __orca_deferred_precmd_fallback + return $__orca_precmd_status +} +__orca_deferred_precmd_fallback() { + builtin emulate -L zsh + if (( \${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then + __orca_deferred_init + fi +} __orca_arm_deferred_line_init() { builtin emulate -L zsh - [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]] && return 0 - if (( \${+widgets[zle-line-init]} )); then - zle -A zle-line-init __orca_saved_line_init + if [[ "\${widgets[zle-line-init]:-}" != user:__orca_deferred_line_init ]]; then + if (( \${+widgets[zle-line-init]} )); then + zle -A zle-line-init __orca_saved_line_init + fi + zle -N zle-line-init __orca_deferred_line_init + fi + if [[ "\${functions[precmd]:-}" != "\${functions[__orca_deferred_precmd]}" ]]; then + if (( \${+functions[precmd]} )); then + functions[__orca_saved_precmd]="\${functions[precmd]}" + fi + functions[precmd]="\${functions[__orca_deferred_precmd]}" fi - zle -N zle-line-init __orca_deferred_line_init }` // Why: restore the exact prior widget before the existing readiness hook captures it. -export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if [[ "\${functions[precmd]:-}" == "\${functions[__orca_deferred_precmd]}" ]]; then + if (( \${+functions[__orca_saved_precmd]} )); then + functions[precmd]="\${functions[__orca_saved_precmd]}" + else + builtin unfunction precmd + fi + fi + if (( \${+widgets[__orca_saved_line_init]} )); then if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init fi @@ -32,7 +67,9 @@ export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_s fi` // Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. -export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` local __orca_widget __orca_line_init_bound=0 +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback + (( \${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd + local __orca_widget __orca_line_init_bound=0 for __orca_widget in "\${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then __orca_line_init_bound=1 From db9634c0c39b22dbb1b94885e1aeb6e7ff6353d6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:56:22 -0700 Subject: [PATCH 86/93] test: replay global Zsh hook replacement after host startup --- ...sh-deferred-startup-line-init.live-shell.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index 23234cb95987..583769352835 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -83,16 +83,13 @@ describe('zsh deferred startup after prompt-hook replacement', () => { ? USER_PRECMD.replace('return 1', 'return 0') : '') ) - if (stockBinding) { - // Ubuntu's global zshrc rebinds this widget between .zshenv and the user's .zshrc. - writeFileSync( - join(home, '.zprofile'), - USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') - ) - } + // Replay Ubuntu's later widget binding before the user's own startup changes. + const stockWidget = stockBinding + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' writeFileSync( join(home, '.zshrc'), - `export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + `${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ chainedLineInit ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` : chainedRedraw From 5098be93ec79b82834fd2dbe109cd5610e3730e1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:01:15 -0700 Subject: [PATCH 87/93] test: isolate controlled Zsh widgets from distro keyboard setup --- src/main/zsh-deferred-startup-line-init.live-shell.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index 583769352835..4c3f12eb919b 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -104,6 +104,8 @@ describe('zsh deferred startup after prompt-hook replacement', () => { HOME: home, USERPROFILE: home, PATH: `${ambientBin}:/usr/bin:/bin`, + // Stock cases replay this replacement after the fixture installs its own widgets. + DEBIAN_PREVENT_KEYBOARD_CHANGES: '1', ZDOTDIR: wrapperDir, ORCA_HISTFILE: join(home, 'scoped-history') } From 0716bd6fc2cd7aa68dd3db3ec0907b256024023c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:28:02 -0700 Subject: [PATCH 88/93] fix(shell): preserve user hooks during deferred zsh initialization --- .../daemon-zsh-zshenv.txt | 43 +++-------- .../local-zsh-zshenv.txt | 43 +++-------- .../relay-zsh-zshenv.txt | 43 +++-------- ...erred-startup-line-init.live-shell.test.ts | 76 ++++++++++++++++--- src/main/zsh-deferred-startup-line-init.ts | 47 +++--------- 5 files changed, 108 insertions(+), 144 deletions(-) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index ae1592f6d7bc..39963451945d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -58,26 +58,14 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -__orca_deferred_precmd() { - local __orca_precmd_status=0 - if (( ${+functions[__orca_saved_precmd]} )); then - local __orca_original_precmd="${functions[__orca_saved_precmd]}" - functions[precmd]="$__orca_original_precmd" - precmd "$@" - __orca_precmd_status=$? - if (( ${+functions[__orca_deferred_init]} )) && - [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then - functions[precmd]="${functions[__orca_deferred_precmd]}" - fi - fi - __orca_deferred_precmd_fallback - return $__orca_precmd_status -} -__orca_deferred_precmd_fallback() { +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? builtin emulate -L zsh - if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then - __orca_deferred_init - fi + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status } __orca_arm_deferred_line_init() { builtin emulate -L zsh @@ -87,11 +75,8 @@ __orca_arm_deferred_line_init() { fi zle -N zle-line-init __orca_deferred_line_init fi - if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[precmd]} )); then - functions[__orca_saved_precmd]="${functions[precmd]}" - fi - functions[precmd]="${functions[__orca_deferred_precmd]}" + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 fi } __orca_deferred_init() { @@ -103,13 +88,6 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions - if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[__orca_saved_precmd]} )); then - functions[precmd]="${functions[__orca_saved_precmd]}" - else - builtin unfunction precmd - fi - fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -273,8 +251,7 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback - (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 6561bf5cbf89..338470d3139d 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -58,26 +58,14 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -__orca_deferred_precmd() { - local __orca_precmd_status=0 - if (( ${+functions[__orca_saved_precmd]} )); then - local __orca_original_precmd="${functions[__orca_saved_precmd]}" - functions[precmd]="$__orca_original_precmd" - precmd "$@" - __orca_precmd_status=$? - if (( ${+functions[__orca_deferred_init]} )) && - [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then - functions[precmd]="${functions[__orca_deferred_precmd]}" - fi - fi - __orca_deferred_precmd_fallback - return $__orca_precmd_status -} -__orca_deferred_precmd_fallback() { +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? builtin emulate -L zsh - if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then - __orca_deferred_init - fi + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status } __orca_arm_deferred_line_init() { builtin emulate -L zsh @@ -87,11 +75,8 @@ __orca_arm_deferred_line_init() { fi zle -N zle-line-init __orca_deferred_line_init fi - if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[precmd]} )); then - functions[__orca_saved_precmd]="${functions[precmd]}" - fi - functions[precmd]="${functions[__orca_deferred_precmd]}" + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 fi } __orca_deferred_init() { @@ -103,13 +88,6 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions - if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[__orca_saved_precmd]} )); then - functions[precmd]="${functions[__orca_saved_precmd]}" - else - builtin unfunction precmd - fi - fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -290,8 +268,7 @@ __orca_deferred_init() { # the permanent hook has not run yet and the first prompt would lose its mark. __orca_has_feature markers && __orca_osc133_precmd builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback - (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index eab61394321c..03da1234baf9 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -44,26 +44,14 @@ __orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -__orca_deferred_precmd() { - local __orca_precmd_status=0 - if (( ${+functions[__orca_saved_precmd]} )); then - local __orca_original_precmd="${functions[__orca_saved_precmd]}" - functions[precmd]="$__orca_original_precmd" - precmd "$@" - __orca_precmd_status=$? - if (( ${+functions[__orca_deferred_init]} )) && - [[ "${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then - functions[precmd]="${functions[__orca_deferred_precmd]}" - fi - fi - __orca_deferred_precmd_fallback - return $__orca_precmd_status -} -__orca_deferred_precmd_fallback() { +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? builtin emulate -L zsh - if (( ${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then - __orca_deferred_init - fi + (( ${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status } __orca_arm_deferred_line_init() { builtin emulate -L zsh @@ -73,11 +61,8 @@ __orca_arm_deferred_line_init() { fi zle -N zle-line-init __orca_deferred_line_init fi - if [[ "${functions[precmd]:-}" != "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[precmd]} )); then - functions[__orca_saved_precmd]="${functions[precmd]}" - fi - functions[precmd]="${functions[__orca_deferred_precmd]}" + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 fi } __orca_deferred_init() { @@ -89,13 +74,6 @@ __orca_deferred_init() { (( $+_orca_deferred_init_done )) && return 0 builtin typeset -g _orca_deferred_init_done=1 builtin typeset -g precmd_functions - if [[ "${functions[precmd]:-}" == "${functions[__orca_deferred_precmd]}" ]]; then - if (( ${+functions[__orca_saved_precmd]} )); then - functions[precmd]="${functions[__orca_saved_precmd]}" - else - builtin unfunction precmd - fi - fi if (( ${+widgets[__orca_saved_line_init]} )); then if [[ "${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init @@ -240,8 +218,7 @@ __orca_deferred_init() { zle -N zle-line-init __orca_prompt_mark fi builtin unset _orca_shell_features _orca_histfile - builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback - (( ${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd + (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init local __orca_widget __orca_line_init_bound=0 for __orca_widget in "${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index 4c3f12eb919b..e980c7b58f02 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -51,7 +51,11 @@ describe('zsh deferred startup after prompt-hook replacement', () => { 'stock-chained-redraw', 'stock-nonzero-precmd', 'ordered-startup', - 'replaced-precmd-startup' + 'replaced-precmd-startup', + 'status-startup', + 'sticky-startup', + 'scheduled-startup', + 'unavailable-startup' ] as const)( 'restores CLI precedence and preserves the user widget in a %s pane', async (intent) => { @@ -62,6 +66,11 @@ describe('zsh deferred startup after prompt-hook replacement', () => { const nonzeroPrecmd = intent === 'stock-nonzero-precmd' const orderedPrecmd = intent === 'ordered-startup' const replacedPrecmd = intent === 'replaced-precmd-startup' + const statusPrecmd = intent === 'status-startup' + const stickyPrecmd = intent === 'sticky-startup' + const scheduledPrecmd = intent === 'scheduled-startup' + const unavailableSched = intent === 'unavailable-startup' + const scheduleCleanup = scheduledPrecmd || unavailableSched || orderedPrecmd const home = mkdtempSync(join(tmpdir(), 'orca-deferred-line-init-')) roots.push(home) const cliBin = join(home, 'cli', 'bin') @@ -77,19 +86,34 @@ describe('zsh deferred startup after prompt-hook replacement', () => { writeFileSync( join(home, '.zshenv'), (chainedLineInit || chainedRedraw ? '' : stockBinding ? USER_REDRAW : USER_WIDGET) + - (nonzeroPrecmd || replacedPrecmd - ? USER_PRECMD - : orderedPrecmd - ? USER_PRECMD.replace('return 1', 'return 0') - : '') + (statusPrecmd + ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' + : stickyPrecmd + ? `emulate sh -c 'precmd() { O_FIRST_IN="\${O_FIRST_IN:-$?:\${options[shwordsplit]}:\${options[ksharrays]}}"; return 1; }'\n` + : scheduledPrecmd + ? 'zmodload zsh/sched\nO_EVENT() { O_EO=${_orca_deferred_init_done:-0}; }\nsched +0 O_EVENT\nsched +3600 O_EVENT\n' + : unavailableSched + ? 'zmodload zsh/zleparameter zsh/terminfo\nO_MP=("${module_path[@]}"); module_path=()\n' + : nonzeroPrecmd || replacedPrecmd + ? USER_PRECMD + : orderedPrecmd + ? USER_PRECMD.replace('return 1', 'return 0') + : '') ) + if (statusPrecmd || stickyPrecmd) { + writeFileSync( + join(home, '.zlogin'), + 'orca_test_status() { return 42; }; orca_test_status\n' + ) + } // Replay Ubuntu's later widget binding before the user's own startup changes. - const stockWidget = stockBinding - ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') - : '' + const stockWidget = + stockBinding || stickyPrecmd || scheduledPrecmd + ? USER_WIDGET.replaceAll('orca_test_line_init', 'zle-line-init') + : '' writeFileSync( join(home, '.zshrc'), - `${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ chainedLineInit ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` : chainedRedraw @@ -122,12 +146,24 @@ describe('zsh deferred startup after prompt-hook replacement', () => { env[POSIX_SHELL_STARTUP_COMMAND_ENV] = 'O_SU=$((${O_SU:-0}+1))' } + if (statusPrecmd || stickyPrecmd) { + const baseline = await runZshPty({ env: { ...env, ZDOTDIR: home }, report: ['O_FIRST_IN'] }) + expect(baseline.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + } + const result = await runZshPty({ env, commands: [ 'O_LK=$(command -v orca-dev)', 'O_IR=${+functions[__orca_deferred_line_init]}', 'O_SR=${+widgets[__orca_saved_line_init]}', + ...(scheduleCleanup + ? [ + 'O_SC=${+functions[__orca_deferred_sched_init]}', + 'O_SE=${zsh_scheduled_events[*]:-UNSET}' + ] + : []), + ...(scheduledPrecmd ? ['O_EV=${#zsh_scheduled_events}'] : []), ...(stockBinding ? ['O_RW=${widgets[zle-line-pre-redraw]:-none}'] : []), 'O_LI=${widgets[zle-line-init]:-none}', 'O_PC="${precmd_functions[*]}"', @@ -139,10 +175,13 @@ describe('zsh deferred startup after prompt-hook replacement', () => { 'O_UN', 'O_IR', 'O_SR', + ...(scheduleCleanup ? ['O_SC', 'O_SE'] : []), + ...(scheduledPrecmd ? ['O_EV', 'O_EO'] : []), ...(stockBinding ? ['O_RW', 'O_RC', 'O_RN'] : []), ...(nonzeroPrecmd ? ['O_PCALLS', 'O_PN', 'O_PO', 'O_AC', 'O_PS'] : []), ...(orderedPrecmd ? ['O_PCALLS', 'O_PN', 'O_AO'] : []), ...(replacedPrecmd ? ['O_PCALLS', 'O_NPC', 'O_NPN'] : []), + ...(statusPrecmd || stickyPrecmd ? ['O_FIRST_IN'] : []), 'O_LI', 'O_PC', 'O_SU', @@ -159,6 +198,19 @@ describe('zsh deferred startup after prompt-hook replacement', () => { expect(result.values.O_IR).toBe('0') } expect(result.values.O_SR).toBe('0') + if (scheduleCleanup) { + expect(result.values.O_SC).toBe('0') + expect(result.values.O_SE).not.toContain('orca') + } + if (unavailableSched) { + expect(result.output).not.toContain('failed to load module') + expect(result.values.O_SE).toBe('UNSET') + } + if (scheduledPrecmd) { + expect(result.values.O_EV).toBe('1') + expect(result.values.O_EO).toBe('0') + expect(result.values.O_SE).toContain('O_EVENT') + } if (stockBinding) { expect(Number(result.values.O_RC)).toBeGreaterThan(0) expect(result.values.O_RN).toBe(chainedRedraw ? 'orca_test_redraw' : 'zle-line-pre-redraw') @@ -184,6 +236,10 @@ describe('zsh deferred startup after prompt-hook replacement', () => { expect(Number(result.values.O_NPC)).toBeGreaterThan(0) expect(result.values.O_NPN).toBe('precmd') } + if (statusPrecmd || stickyPrecmd) { + expect(result.values.O_FIRST_IN).toBe(stickyPrecmd ? '42:on:on' : '42') + expect(result.output).toContain('ORCA_FIRST_PROMPT:42 ') + } if (historyOnly) { expect(result.output).not.toContain('\x1b]133;') expect(result.values.O_LI).toBe( diff --git a/src/main/zsh-deferred-startup-line-init.ts b/src/main/zsh-deferred-startup-line-init.ts index d3d76f01064a..95b9da94df67 100644 --- a/src/main/zsh-deferred-startup-line-init.ts +++ b/src/main/zsh-deferred-startup-line-init.ts @@ -1,4 +1,4 @@ -// Why: user startup files can replace the prompt array and global zshrc can rebind line-init. +// Why: stock zshrc can replace line-init after the user clears the prompt-hook array. export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { builtin emulate -L zsh (( \${+functions[__orca_deferred_init]} )) || return 0 @@ -12,26 +12,14 @@ export const ZSH_DEFERRED_LINE_INIT_BLOCK = `__orca_deferred_line_init() { __orca_prompt_mark "$@" fi } -__orca_deferred_precmd() { - local __orca_precmd_status=0 - if (( \${+functions[__orca_saved_precmd]} )); then - local __orca_original_precmd="\${functions[__orca_saved_precmd]}" - functions[precmd]="$__orca_original_precmd" - precmd "$@" - __orca_precmd_status=$? - if (( \${+functions[__orca_deferred_init]} )) && - [[ "\${functions[precmd]:-}" == "$__orca_original_precmd" ]]; then - functions[precmd]="\${functions[__orca_deferred_precmd]}" - fi - fi - __orca_deferred_precmd_fallback - return $__orca_precmd_status -} -__orca_deferred_precmd_fallback() { +# Why: scheduled callbacks run after user prompt hooks without copying their function metadata. +__orca_deferred_sched_init() { + local __orca_prompt_status=$? builtin emulate -L zsh - if (( \${+functions[__orca_deferred_init]} && ! precmd_functions[(Ie)__orca_deferred_init] )); then - __orca_deferred_init - fi + (( \${+functions[__orca_deferred_init]} )) && __orca_deferred_init + builtin unset __orca_deferred_sched_armed + builtin unfunction __orca_deferred_sched_init + return $__orca_prompt_status } __orca_arm_deferred_line_init() { builtin emulate -L zsh @@ -41,23 +29,13 @@ __orca_arm_deferred_line_init() { fi zle -N zle-line-init __orca_deferred_line_init fi - if [[ "\${functions[precmd]:-}" != "\${functions[__orca_deferred_precmd]}" ]]; then - if (( \${+functions[precmd]} )); then - functions[__orca_saved_precmd]="\${functions[precmd]}" - fi - functions[precmd]="\${functions[__orca_deferred_precmd]}" + if (( ! $+__orca_deferred_sched_armed )) && builtin zmodload -F zsh/sched b:sched 2>/dev/null; then + builtin sched +0 __orca_deferred_sched_init && builtin typeset -g __orca_deferred_sched_armed=1 fi }` // Why: restore the exact prior widget before the existing readiness hook captures it. -export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if [[ "\${functions[precmd]:-}" == "\${functions[__orca_deferred_precmd]}" ]]; then - if (( \${+functions[__orca_saved_precmd]} )); then - functions[precmd]="\${functions[__orca_saved_precmd]}" - else - builtin unfunction precmd - fi - fi - if (( \${+widgets[__orca_saved_line_init]} )); then +export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if (( \${+widgets[__orca_saved_line_init]} )); then if [[ "\${widgets[zle-line-init]:-}" == user:__orca_deferred_line_init ]]; then zle -A __orca_saved_line_init zle-line-init fi @@ -67,8 +45,7 @@ export const ZSH_DEFERRED_LINE_INIT_RETIRE_BLOCK = ` if [[ "\${functions[precmd fi` // Why: add-zle-hook-widget can keep an alias of the bootstrap in its own chain. -export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` builtin unfunction __orca_deferred_precmd __orca_deferred_precmd_fallback - (( \${+functions[__orca_saved_precmd]} )) && builtin unfunction __orca_saved_precmd +export const ZSH_DEFERRED_LINE_INIT_CLEANUP_BLOCK = ` (( $+__orca_deferred_sched_armed )) || builtin unfunction __orca_deferred_sched_init local __orca_widget __orca_line_init_bound=0 for __orca_widget in "\${(v)widgets[@]}"; do if [[ "$__orca_widget" == user:__orca_deferred_line_init ]]; then From 81354ff9ae4838b6bd9ce581ac08ded23eda0e31 Mon Sep 17 00:00:00 2001 From: Orca Date: Sun, 4 Oct 2026 00:15:49 -0700 Subject: [PATCH 89/93] Retry interrupted OpenCode startup prompt claims --- .../server-transport-interference.test.ts | 45 ++++++++++++++++++- .../agent-hooks/server/server-lifecycle.ts | 5 +++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/agent-hooks/server-transport-interference.test.ts b/src/main/agent-hooks/server-transport-interference.test.ts index 37c80087bdb8..e8d5a8e357b1 100644 --- a/src/main/agent-hooks/server-transport-interference.test.ts +++ b/src/main/agent-hooks/server-transport-interference.test.ts @@ -2,9 +2,11 @@ // short of its own Content-Length. The listener fails open on every request error, so the only // way this stays diagnosable is if the truncation is classified before it is swallowed. import { connect } from 'node:net' +import { ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference' import { AgentHookServer } from './server' +import { OPENCODE_STARTUP_PROMPT_CLAIM_PATH } from '../../shared/opencode-startup-prompt' async function postTruncatedHook( port: number, @@ -40,11 +42,15 @@ async function postTruncatedHook( } /** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */ -async function postStalledHook(port: number, token: string): Promise { +async function postStalledHook( + port: number, + token: string, + pathname = '/hook/claude' +): Promise { const socket = connect({ port, host: '127.0.0.1' }) await new Promise((resolve) => socket.on('connect', () => resolve())) socket.write( - `POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` + `POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n` ) await new Promise((resolve) => { socket.on('close', () => resolve()) @@ -118,6 +124,41 @@ describe('AgentHookServer transport interference', () => { expect(reports).toHaveLength(1) }, 20_000) + it('classifies an interrupted startup claim as retryable without consuming it', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postTruncatedHook(port, token, { + pathname: OPENCODE_STARTUP_PROMPT_CLAIM_PATH, + sentBytes: '{"nonce":', + announcedLength: 1000 + }) + // The reset peer cannot receive this response; observe the real handler's classification. + expect(writeHead.mock.calls).toEqual([[503]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }) + + it('keeps a startup claim stopped by its own slowloris cap as a denial', async () => { + const { server, port, token, reports } = await startServer() + const claim = vi.fn(() => true) + server.setStartupPromptClaimListener(claim, () => {}) + const writeHead = vi.spyOn(ServerResponse.prototype, 'writeHead') + try { + await postStalledHook(port, token, OPENCODE_STARTUP_PROMPT_CLAIM_PATH) + expect(writeHead.mock.calls).toEqual([[200, { 'content-type': 'application/json' }]]) + expect(claim).not.toHaveBeenCalled() + expect(reports).toEqual([]) + } finally { + writeHead.mockRestore() + } + }, 30_000) + it('never reports for POSTs that deliver their whole body', async () => { const { port, token, reports } = await startServer() diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 7f1d449f2ab0..825b51b8a63f 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -152,6 +152,11 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook res.end() } catch (error) { if (pathname === OPENCODE_STARTUP_PROMPT_CLAIM_PATH) { + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + res.writeHead(503) + res.end() + return + } res.writeHead(200, { 'content-type': 'application/json' }) res.end('{"allowed":false}') return From 811a3593d338bde35369fdc902d090e746a1d0c9 Mon Sep 17 00:00:00 2001 From: Orca campaign Date: Sun, 4 Oct 2026 01:12:07 -0700 Subject: [PATCH 90/93] Keep completed Zsh startup hooks retired when the wrapper is sourced again --- .../daemon-zsh-zshenv.txt | 8 +++--- .../local-zsh-zshenv.txt | 8 +++--- .../relay-zsh-zshenv.txt | 8 +++--- src/main/shell-templates.ts | 8 +++--- ...erred-startup-line-init.live-shell.test.ts | 26 +++++++++++++++---- 5 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt index 39963451945d..18850b4a0efc 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/daemon-zsh-zshenv.txt @@ -267,7 +267,9 @@ __orca_deferred_init() { [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) - __orca_arm_deferred_line_init + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt index 338470d3139d..b3c8a92869ca 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/local-zsh-zshenv.txt @@ -284,7 +284,9 @@ __orca_deferred_init() { [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) - __orca_arm_deferred_line_init + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt index 03da1234baf9..21dce8480e63 100644 --- a/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt +++ b/src/main/__fixtures__/shell-wrapper-snapshots/relay-zsh-zshenv.txt @@ -234,7 +234,9 @@ __orca_deferred_init() { [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) - __orca_arm_deferred_line_init + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( ${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi } diff --git a/src/main/shell-templates.ts b/src/main/shell-templates.ts index b4eda05beb28..7b5f922e89f5 100644 --- a/src/main/shell-templates.ts +++ b/src/main/shell-templates.ts @@ -102,9 +102,11 @@ export const ZSH_USER_ZSHENV_SOURCE_BLOCK = `{ [[ ! -r "$_orca_user_zshenv" ]] || builtin source -- "$_orca_user_zshenv" } always { builtin unset _orca_user_zshenv - builtin typeset -ag precmd_functions - (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) - __orca_arm_deferred_line_init + if (( ! $+_orca_deferred_init_done )); then + builtin typeset -ag precmd_functions + (( \${precmd_functions[(Ie)__orca_deferred_init]} )) || precmd_functions+=(__orca_deferred_init) + __orca_arm_deferred_line_init + fi }` // Why: daemon, local, and relay wrappers must preserve one Bash prompt-hook contract. diff --git a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts index e980c7b58f02..48246e10573a 100644 --- a/src/main/zsh-deferred-startup-line-init.live-shell.test.ts +++ b/src/main/zsh-deferred-startup-line-init.live-shell.test.ts @@ -55,11 +55,14 @@ describe('zsh deferred startup after prompt-hook replacement', () => { 'status-startup', 'sticky-startup', 'scheduled-startup', - 'unavailable-startup' + 'unavailable-startup', + 'repeat-history', + 'repeat-startup' ] as const)( 'restores CLI precedence and preserves the user widget in a %s pane', async (intent) => { const historyOnly = intent.endsWith('history') + const repeatSource = intent.startsWith('repeat-') const stockBinding = intent.startsWith('stock-') const chainedLineInit = intent === 'chained-startup' const chainedRedraw = intent === 'stock-chained-redraw' @@ -85,7 +88,11 @@ describe('zsh deferred startup after prompt-hook replacement', () => { } writeFileSync( join(home, '.zshenv'), - (chainedLineInit || chainedRedraw ? '' : stockBinding ? USER_REDRAW : USER_WIDGET) + + (chainedLineInit || chainedRedraw || repeatSource + ? '' + : stockBinding + ? USER_REDRAW + : USER_WIDGET) + (statusPrecmd ? 'precmd() { O_FIRST_IN=${O_FIRST_IN:-$?}; }\n' : stickyPrecmd @@ -113,7 +120,7 @@ describe('zsh deferred startup after prompt-hook replacement', () => { : '' writeFileSync( join(home, '.zshrc'), - `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ + `${statusPrecmd || stickyPrecmd ? 'PS1="ORCA_FIRST_PROMPT:%? "\n' : ''}${repeatSource ? USER_WIDGET : ''}${unavailableSched ? 'module_path=("${O_MP[@]}")\n' : ''}${stockWidget}export PATH="$HOME/ambient-bin:/usr/bin:/bin:$HOME/cli/bin"\n${orderedPrecmd ? '' : 'precmd_functions=()\n'}${ chainedLineInit ? `${USER_WIDGET.replace('zle -N zle-line-init orca_test_line_init', 'zle -N orca_test_line_init')}autoload -Uz add-zle-hook-widget\nadd-zle-hook-widget line-init orca_test_line_init\n` : chainedRedraw @@ -154,6 +161,9 @@ describe('zsh deferred startup after prompt-hook replacement', () => { const result = await runZshPty({ env, commands: [ + ...(repeatSource + ? ['source -- "$HOME/wrapper/.zshenv"', 'source -- "$HOME/wrapper/.zshenv"'] + : []), 'O_LK=$(command -v orca-dev)', 'O_IR=${+functions[__orca_deferred_line_init]}', 'O_SR=${+widgets[__orca_saved_line_init]}', @@ -194,16 +204,22 @@ describe('zsh deferred startup after prompt-hook replacement', () => { if (!chainedLineInit) { expect(result.values.O_UN).toBe('zle-line-init') } - if (!chainedLineInit && !chainedRedraw) { + if (!chainedLineInit && !chainedRedraw && !repeatSource) { expect(result.values.O_IR).toBe('0') } expect(result.values.O_SR).toBe('0') + if (repeatSource) { + expect(result.output).not.toContain('job table full or recursion limit exceeded') + expect(result.values.O_PC).not.toContain('__orca_deferred_init') + } if (scheduleCleanup) { expect(result.values.O_SC).toBe('0') expect(result.values.O_SE).not.toContain('orca') } if (unavailableSched) { - expect(result.output).not.toContain('failed to load module') + // Stock completion modules can fail before the fixture restores module_path. + expect(result.output).not.toContain('zsh/sched') + expect(result.output).not.toContain('__orca_arm_deferred_line_init:') expect(result.values.O_SE).toBe('UNSET') } if (scheduledPrecmd) { From 7deb759f253acc9a13a6ad345efd810ac9588f2a Mon Sep 17 00:00:00 2001 From: OpenCode campaign Date: Sun, 4 Oct 2026 14:14:04 -0700 Subject: [PATCH 91/93] Reject truncated OpenCode catalogs and explain worktree model limits --- docs/site/content/docs/cli/orchestration.mdx | 2 +- .../orchestration/references/coordinator-loop.md | 9 ++++++--- src/cli/bundled-skill-guides.ts | 4 ++-- src/cli/specs/orchestration-worker-specs.ts | 2 +- src/main/opencode/opencode-model-availability.test.ts | 11 +++++++++++ src/main/opencode/opencode-model-availability.ts | 1 + .../worker/worker-launch-preferences.test.ts | 11 +++++++++++ .../orchestration/worker/worker-launch-preferences.ts | 8 ++++++++ .../orchestration/worker/worker-start-validation.ts | 4 ++++ .../orchestration/worker/workers-new-worktree.test.ts | 10 ++++++++-- 10 files changed, 53 insertions(+), 9 deletions(-) diff --git a/docs/site/content/docs/cli/orchestration.mdx b/docs/site/content/docs/cli/orchestration.mdx index 688fe0f621d9..88355cb8c825 100644 --- a/docs/site/content/docs/cli/orchestration.mdx +++ b/docs/site/content/docs/cli/orchestration.mdx @@ -44,7 +44,7 @@ orca orchestration worker-start --task --worktree new-child --name bill orca orchestration worker-start --task --worktree current --agent claude --model --effort high --json ``` -`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); OpenCode accepts a per-launch model only when the execution host verifies its CLI version and model availability; OpenCode effort remains unsupported. Other agents run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. +`--agent` takes any Orca agent ID enabled on the worker server, for example `claude`, `codex`, `cursor`, `antigravity`, `muse`, `opencode`, or `opencode2`. `--model` accepts opaque provider model IDs for Claude, Codex, Cursor, Antigravity, and Muse (for example `--agent muse --model muse-spark-1.3`); OpenCode accepts a per-launch model only in an existing worktree, when the execution host verifies its CLI version and model availability; OpenCode effort remains unsupported. Other agents run the model from their own config. `--effort` requires `--model` and only applies when that agent/model supports the level. Neither flag can combine with `--terminal` (reuse an existing pane). Overrides apply to that launch only and show under `launch.requested` / `launch.effective` in the start receipt. Federated starts need a worker host that advertises launch-preference support. Wait for completions (process every message in a Delivery, then ack): diff --git a/skill-guides/orchestration/references/coordinator-loop.md b/skill-guides/orchestration/references/coordinator-loop.md index 764d0163b45f..164f91e4548f 100644 --- a/skill-guides/orchestration/references/coordinator-loop.md +++ b/skill-guides/orchestration/references/coordinator-loop.md @@ -31,9 +31,12 @@ ORCA orchestration worker-start --task --worktree current --agent clau ORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json ``` -OpenCode also accepts `--model` when the execution host verifies its CLI version -and model availability. OpenCode effort is unsupported. Omit the model to inherit -its configuration; unsupported or unknown hosts refuse the override explicitly. +OpenCode also accepts `--model` in an existing worktree when the execution host +verifies its CLI version and model availability. Creating a new worktree with an +OpenCode model override is unsupported; use `--worktree current` or an existing +worktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the +model to inherit its configuration; unsupported or unknown hosts refuse the +override explicitly. `--effort` requires `--model`; neither option combines with `--terminal`. A connected worker server must advertise launch-preference support before Orca diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index a0ae577e7695..f23fe070c1f9 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -69,10 +69,10 @@ const ORCA_PER_WORKSPACE_ENV_WINDOWS_SCRIPTS_REFERENCE_MARKDOWN = "# Windows loc const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n" // oxfmt-ignore -const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` when the execution host verifies its CLI version\nand model availability. OpenCode effort is unsupported. Omit the model to inherit\nits configuration; unsupported or unknown hosts refuse the override explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" +const ORCHESTRATION_FULL_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Coordinate supervised Orca workers: threaded messages, blocking ask/reply,\n task dispatch, worker_done/escalation waits, task DAGs, decision gates,\n coordinator loops, and decomposing work across agents. Use `orca-cli` for full\n ownership handoffs — \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", \"another worktree\" — unless asked to supervise, monitor, or coordinate\n a DAG, and for terminal control, lightweight terminal prompts, shell commands,\n Orca worktree management, and reading or waiting on terminals.\n---\n\n# Orca orchestration\n\nOrchestration is Orca's structured coordination layer. It records who owns work,\nwhich attempt is authoritative, and when supervised work has settled.\n\n## Outcome\n\n**Result:** every in-scope Task has one explicit outcome and every settled worker\nterminal has a next owner or cleanup decision. **Next consumer:** the user who\nrequested supervision. **Done:** all expected Dispatches have settled, every\ndelivered message was processed before acknowledgment, each settled worker was\nreused, explicitly retained, or released, and the turn ends only when the report\nto that user names, per Task, its outcome, the evidence behind it, and any\nunresolved blocker.\n\n**Safe failure:** preserve work and authority and report the state as unknown or\n`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,\nand only an accepted settlement authorizes release. Every other observation,\nabsence included, is a checkpoint.\n\n## Classify the role\n\n| Current context | Role | Route |\n| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |\n| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |\n| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |\n| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |\n| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |\n| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |\n\nModel or effort selection does not make a handoff supervised. Never substitute a\nnon-Orca subagent tool when Orca orchestration provenance was requested.\n\n## Authority and safety floor\n\n- A Run is a durable namespace and coordinator inbox; it does not schedule or\n place workers. A Task is work. A Dispatch is one authoritative Task attempt.\n- Lifecycle authority comes from the active Dispatch, not a terminal title,\n copied ID, old database row, provider transcript, or visible pane.\n- Workers use the exact executable, handle, Task ID, and Dispatch ID in the live\n preamble, plus any other flag it carries. Never reconstruct, translate, or\n broaden those arguments.\n- After remote start, address the worker by Dispatch ID. The execution host owns\n process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts\n `live` / `unverifiable` / `exited`; contact loss is not process death.\n- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict\n for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live\n terminal can still hold a dead or stuck agent.\n- Folder workspaces are valid; never require Git or assume a worktree.\n- Clients and remote servers update independently. Treat unknown optional fields\n as absent. A new stream operation requires advertised capability because old\n decoders may silently drop unknown opcodes. Never fall back to local execution\n when remote authority or capability is unproven.\n- Use the executable you used to run `skills get` for the entire run. In the\n examples below, replace `ORCA` with it; do not create a shell variable or run\n `ORCA` literally. If it fails, report that exact error instead of switching.\n- `ORCA status --json` shows your Orca session ID as `caller.orcaSessionId` when you have one.\n- A successful `orchestration send` proves durable enqueue; its wake or nudge is\n best-effort attention only and does not prove the recipient read or accepted it.\n\n## Worker obligations\n\nThe injected preamble is authoritative. A dispatched worker must:\n\n1. Do only the current Task and use the preamble's `ask` command for a blocking\n coordinator question. Never open a local question TUI the coordinator cannot\n answer. Resume the same message ID after an ask timeout.\n2. Send heartbeats only at the cadence in the preamble. A heartbeat proves\n liveness, not completion.\n3. Read coordinator follow-ups at each natural checkpoint — before starting a\n new file, after a test run — and once more immediately before `worker_done`:\n `ORCA orchestration check --terminal --json`.\n4. Send `worker_done` exactly once, from the dispatched terminal, with a\n three-sentence executive summary, both lifecycle IDs, and explicit\n `--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.\n5. Append `--files-modified` and `--report-path` only with real values when\n applicable. After `worker_done`, end the dispatched turn and idle; do not poll\n or start new work.\n\nA direct user instruction after completion starts new user-owned work and takes\nprecedence over the idle rule. Do not reuse the settled lifecycle IDs.\n\n## Canonical supervised loop\n\nConfirm the runtime, bind one Run, and start the full independent wave before\nwaiting. `worker-start --spec` creates the Task and its attempt in one call:\n\n```text\nORCA status --json\nORCA orchestration run-create --objective \"\" --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent codex --json\nORCA orchestration worker-start --spec \"\" --worktree current --agent claude --json\nORCA orchestration check --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nIf `worker-start` exits non-zero, do not relaunch. Read the receipt's\n`failedStage` and `residualResources`, then load\n`references/recovery-and-cleanup.md`.\n\nUse `task-create` plus `worker-start --task ` for planned fan-out with\ndependencies or a retry of a known Task. Use dependencies only for real ordering\nand prefer parallel waves over chains deeper than three or four steps; nested\nworkers obey the depth limit, and a new Run does not reset the caller's depth.\n\nA consuming `check` names its caller with `--terminal `, never `--from`;\nomit it inside the coordinator's own Orca terminal. It returns the bound Run's\noldest FIFO Delivery and replays that batch until acknowledged. Process every\nmessage: reply to questions, validate each `worker_done` against the expected\nactive Dispatch, and decide each settled terminal's next owner before the ack:\n\n```text\nORCA orchestration reply --id --body \"\" --json\nORCA orchestration worker-release --dispatch --json\nORCA orchestration check --ack --wait --types \"worker_done,escalation,question\" --timeout-ms 900000 --json\n```\n\nKeep waiting until every expected Dispatch settles. A timeout or empty result is\na checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate\neditor without the positive proof `## Outcome` requires.\n\nAfter three consecutive empty waits, stop waiting blindly and enumerate with\n`ORCA orchestration worker-list --include-remote --json` (defaults to the bound\nRun; `--run ` overrides; the receipt's `scope` names which), acting on\neach row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.\nRows come newest first and page at 100: while `page.hasMore`, follow `page.nextCursor` with `--cursor `.\nA `none` `nextAction` has no argv to run: read `liveness.reason` and keep waiting\nwith `check --wait`. Absence never earns an argv; settlement and pending work still do.\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose\n`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,\nincluding when `worker-show` reports `agentWait` null. Absence never authorizes\nstop, abandon, retry, or release; keep waiting or inspect.\n\n`worker-start` is the normal path, composing placement, terminal readiness,\nprompt injection, and supervised resource ownership. `dispatch --inject` leaves\nan operator-created process unsupervised and is only for an expressiveness gap.\n\n## Task-spec contract\n\nEvery Task spec must be self-contained and name:\n\n- **Target:** the files, component, or environment in scope.\n- **Change:** the concrete result to produce.\n- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.\n- **Ownership:** what this worker may edit and any coordination boundary.\n- **Observable acceptance:** the test, output, or evidence that proves completion.\n\n## Completion accounting\n\nAfter an accepted success or failure report, immediately do exactly one:\n\n1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.\n2. Record user-requested retention with `worker-retain`.\n3. Run `worker-release`.\n\nRelease is post-settlement cleanup, not cancellation. Only an accepted\nsettlement authorizes it; no other observation does. If release is uncertain,\nfollow its exact recovery receipt and never substitute `terminal close`.\n\nA valid `worker_done` settles the Task and Dispatch automatically; do not follow\nit with `task-update --status completed`. Enumerate the terminals still owing a\ndecision with `worker-list --run --terminal-state reclaimable --json`,\nand do not end the coordinator turn until it returns none.\n\n## Conditional references\n\nThis compact guide is sufficient for the normal local loop. At an action gate\nbelow, run `ORCA skills get orchestration --reference references/.md` and\nread only that document; `--references` lists the names. If the CLI rejects\n`--reference`, run `ORCA skills get orchestration --full` once instead: it\nreturns this exact kernel and every reference, so read only the named one. If an\nolder CLI rejects `--full`, keep this kernel's safety floor, use that command's\n`--help`, and never guess newer flags.\n\n| Action gate | Bundled reference |\n| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |\n| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |\n| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |\n| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |\n| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |\n| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |\n| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |\n| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |\n\nRetired scheduler commands are not aliases for Run creation. Recovery commands\nmust provide their exact next action; follow it with the same selected executable.\n\n---\n\n# Bundled references\n\nThese references belong to the version-matched guide above. Read only the documents named by its action gates.\n\n\n\n# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` in an existing worktree when the execution host\nverifies its CLI version and model availability. Creating a new worktree with an\nOpenCode model override is unsupported; use `--worktree current` or an existing\nworktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the\nmodel to inherit its configuration; unsupported or unknown hosts refuse the\noverride explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n\n\n\n# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n\n\n\n# Low-level topology\n\nLoad this reference only when `worker-start` cannot express required custom argv\nor terminal topology. It is not the normal supervised loop and is never a full\nhandoff recipe.\n\n```text\nORCA terminal create --worktree active --title --command \"\" --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\nORCA orchestration dispatch --task --to --inject --json\n```\n\nWait for readiness only when startup could lose injected input. Prefer\nagent-first `worker-start` whenever its argv and topology are sufficient.\n\n`dispatch --inject` creates authoritative Task/Dispatch context but deliberately\nkeeps an operator-created process unsupervised: it creates no supervised worker\nresource row. `worker-show`, `worker-read`, and `worker-list` report the lane as\n`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and\nsettled retain/release take no process action.\n\nUse `worker-start --terminal ` when lifecycle ownership of an existing\nagent terminal is required. Never imply that low-level dispatch retroactively\nowns a process, never use it to route around the nested-depth limit, and never\nuse it for an ownership handoff.\n\n\n\n# Messaging and gates\n\nLoad this reference for inbox replay, attempt-specific guidance, group\naddresses, blocking questions, or coordinator-managed DAG decisions.\n\nA successful `send` proves durable enqueue. Wake and nudge are best-effort\nattention only: neither proves the recipient read the message, began a turn, or\naccepted steering.\n\n## Coordinator delivery loop\n\n`check` names its caller with `--terminal ` and is the only verb that\nrejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves\nthe caller; pass it explicitly from anywhere else, including a dispatched\nworker reading coordinator follow-ups.\n\nA consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,\nup to 50 messages, and replays that exact batch until acknowledged. Process\nevery row and required terminal ownership decision before `--ack`. Type filters\ndecide when a waiter wakes; they do not authorize skipping older actionable\nmail. A Delivery therefore always carries the whole FIFO batch whatever its\ntypes, and a `check` without `--wait` hands that batch over unfiltered.\n`--peek` and `--all` are read-only inspection, not progress through the\ncoordinator inbox.\n\nAn empty wait or timeout is a checkpoint. Continue rolling waits until every\nexpected Dispatch settles. Heartbeat or visible activity means alive, not done.\n\n## Addresses\n\nUse a stable Dispatch address for attempt-specific coordinator guidance:\n\n```text\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nDo not substitute a remote terminal handle. Omit `--from` for ordinary\ncoordinator calls; a dispatched worker instead copies the exact `--from` and\nother arguments in its preamble. `check` is the exception: it identifies\nits caller with `--terminal`, never `--from`.\n\nGroup addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,\n`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. Every group but\n`@worktree:` means the live Dispatches of the sender's own Run. Mail goes\nto each `dispatch:` mailbox, except a worker coordinating a child Run\nreceives it in that `run:` mailbox. A sender bound to no Run is refused;\n`--run` must match the group audience and never grants membership.\nA Run group excludes its owning coordinator; a worker raising a blocker sends\nto `run:`. A worker that created its own Run addresses that Run's workers,\nnot its siblings. `@worktree:` reaches matching workspace terminals,\nincluding coordinators. Use groups only for intentional fan-out status or\nquestions. `worker_done`, heartbeat, and other\nDispatch lifecycle messages never target groups.\n\n## Questions and gates\n\nA worker uses `ask`; its timeout leaves one durable question pending, which the\nworker resumes by message ID. The coordinator answers that message with `reply`.\n\nUse a gate only for a coordinator-owned Task-DAG decision:\n\n```text\nORCA orchestration gate-create --task --question \"\" --options --json\nORCA orchestration gate-resolve --id --resolution \"\" --json\nORCA orchestration gate-list --task --json\n```\n\nPass `json_array` using the quoting rules of the active shell; do not copy POSIX\nsingle-quote syntax into PowerShell or `cmd.exe`.\n\nDo not create a gate merely to answer a worker's `ask`.\n\n\n\n# Placement and remote execution\n\nLoad this reference before creating a new worktree or placing work through SSH,\nWSL, or another connected Orca server.\n\n## Placement choices\n\nA fresh worker means a fresh agent terminal, not a new Git worktree. Use the\ncurrent or an exact existing workspace by default. Create a worktree only when\nthe user requested one or a concrete checkout or filesystem conflict makes\nsharing unsafe.\n\n```text\n# Current workspace; setup is not rerun.\nORCA orchestration worker-start --task --worktree current --agent codex --json\n\n# Stacked child worktree.\nORCA orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n\n# Independent top-level worktree.\nORCA orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nCurrent and exact existing workspaces create a fresh terminal unless\n`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git\nor require worktree lineage when the selected workspace is a folder.\n\nRegister a folder workspace through project setup. `repo add --path `\nrequires a valid Git repository and rejects a plain directory:\n\n```text\nORCA project setup-existing-folder --project --host --path --kind folder --json\n```\n\nThen place work on the returned workspace with an exact selector. A worktree\nselector needs the full `::` value Orca returned, passed as\n`id:`; a bare repo id is not a worktree id. `new-child` and\n`new-top-level` are worktree creation and do not apply to a folder.\n\nNew worktrees use agent-first creation and run setup by default. Preserve the\nrepository's startup policy: `start-immediately` can report setup as `running`,\nwhile `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,\nfilesystem isolation, coordination parentage, UI grouping, and execution host\nare separate decisions.\n\n## Connected servers\n\nThe Run and Tasks remain authoritative on the current server. `--on` selects\nonly the worker's execution server and appears only on `worker-start`:\n\n```text\nORCA orchestration worker-start --task --on --worktree new-top-level --repo --name --agent codex --setup run --json\n```\n\nRemote `current` and `new-child` are invalid because they are ambiguous across\nservers. Use an exact discovered remote workspace, or `new-top-level` with an\nexact remote repository selector. After start, route every follow-up, read,\nstop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote\nterminal handle.\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\nORCA orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\nORCA orchestration worker-list --run --include-remote --json\n```\n\n`worker-list` reads local fleet state only; enumerate remote workers with\n`--include-remote` or every one of them reads `unverifiable`. Scope every list\nwith `--run `: unscoped, it reports every Dispatch this runtime has\nrecorded, and the workers you are waiting on are lost in that history.\n\n## Execution-host and mixed-version floor\n\nThe execution host owns process, filesystem, transcript, stop, and cleanup\nfacts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay\nabsence, missing client inventory, or timeout yields `unverifiable`, never\nsynthetic exit and never a client-local substitute action.\n\nClients and servers update independently. Optional response fields may be\nabsent. Forward model/effort, transcript reads, cleanup, or another new remote\noperation only when the peer advertises the relevant capability; unknown stream\nopcodes can be silently dropped. A narrow unsupported response may degrade to a\ndocumented older path, but must not broaden the target or cross the execution\nboundary. Changing host-published content reaches old clients even without a\nwire-shape change, so preserve established semantics or negotiate the behavior.\n\nFor WSL, use the exact executable and arguments returned by Orca so the distro\nand packaged launcher remain bound. Do not translate a printed `orca-ide`\nrecovery command into a PATH-resolved local command.\n\n\n\n# Recovery and cleanup\n\nLoad this reference only after a failed/stopped/unknown attempt, explicit retry\ndecision, stop/abandon request, retention request, or uncertain release.\n\n| Proven state | Safe action |\n| ----------------------- | ------------------------------------------------------------------ |\n| `ready` or active | Keep waiting; optionally read bounded output |\n| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |\n| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |\n| Accepted `worker_done` | Reuse, retain, or release |\n| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |\n| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |\n| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |\n\n## Inspect before acting\n\n```text\nORCA orchestration worker-list --run --json\nORCA orchestration worker-list --run --include-remote --json\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-read --dispatch --limit 50 --json\n```\n\n`worker-list` is the enumerating command and the authority on agent liveness:\neach row carries `projection.liveness`, `projection.attention.categories`,\n`projection.attention.requiresAction`, and a literal `projection.nextAction`\nargv to run. Always scope it with `--run `; an unscoped list reports\nevery Dispatch this runtime has ever recorded and buries the live ones.\n`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal\nwhose agent died at a trust prompt still reads `live` there.\n\nWhen the two disagree, the fleet verdict decides — unless the fleet row is\n`unverifiable` for a reason that names a gap on this client rather than a fact\nabout the worker. `missing_status`, `host_unavailable`, and\n`capability_unsupported` are such gaps: the first means this runtime holds no\nstatus row, the second that it could not ask the execution host at all, and the\nthird that a stale peer answered but lacks the fleet-snapshot capability.\nAgainst any of them, a `worker-show` verdict sourced from the execution host is\nthe better evidence and outranks the row. Only `host_unavailable` is contact\nloss; the other two mean the host was never asked or answered without the\ncapability.\n\nThis never promotes absence. `unverifiable` from either command still authorizes\nnothing — only a positive `live` or `exited` verdict does.\n\nA worker started with `--on ` reads `unverifiable` until you\nenumerate with `--include-remote`, which asks its execution host for the\nverdict. Rows come newest first and past 100 the response pages, so follow\n`page.nextCursor` with `--cursor ` until `page.hasMore` is false.\n\n## Stall needs positive evidence\n\nLeave the wait only on positive proof the agent stopped: `exited` liveness, the\nworker's own observation of process exit, or a transcript whose final agent turn\nsent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.\n\n`unverifiable` is always absence — `missing_status`, `stale_status`,\n`restored_unconfirmed`, or a remote worker with no connection — and a null\n`agentWait` or an unchanged `worker-read` tail is that same absence seen again.\nAbsence never authorizes stop, abandon, retry, or release: keep waiting, or\ninspect until you hold one of the positive signals above. A `nextAction` that\nnames an inspecting command is asking for evidence, not for cleanup.\n\n`worker-read --source auto` uses a proven provider transcript when available and\notherwise returns bounded terminal output with a typed `fallbackReason`.\nContinue with its top-level cursor, which is pinned to that source. If Orca\nreports `source_changed`, restart without the old cursor. A bounded initial\ntranscript tail can return an EOF cursor that follows only newly appended records;\nread `contentComplete`, `clipping`, and `warnings` before assuming omitted older\nrecords are pageable. Never guess a provider session ID, transcript path, or\nremote terminal handle.\n\n## Was the mutation applied?\n\nWhen a mutation's response was lost and named no Dispatch, do not replay blind.\nEvery orchestration mutation accepts `--retry-request `, which reuses one\noperation identity so Orca can replay, join, or recover it instead of starting a\nduplicate. Ask what happened first:\n\n```text\nORCA orchestration request-show --request --json\n```\n\n`completed` means the mutation already took effect; read its recorded receipt\ninstead of rerunning. `pending` means the original mutation is still running or\nOrca restarted before recording its outcome; replay the original command with\n`--retry-request `. `absent` means this runtime holds no receipt\nunder your caller identity — that is not proof nothing happened, so inspect the\naffected Task, Dispatch, and terminal before deciding whether to retry.\n\nWhen a worker's terminal accepted input but the submit is unconfirmed, use\n`terminal send --wait-submit `: it observes the accepted prompt for that\nlong and, on timeout, returns the input-accepted receipt without resending.\n\n## Refused starts\n\n`dispatch` and `worker-start` refuse the following preflight cases with a stable\n`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`\nas the exact recovery text. Older hosts may omit `data`, so treat every field as\noptional.\n\n| Code | Meaning | Recovery |\n| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |\n| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |\n| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |\n| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |\n| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |\n\n## Retry, stop, and abandon\n\nRetry only a positively proven failed or stopped attempt. Name the failed Task\nwith `--task`, since `--spec` creates a new one. Placement is never silently\ninherited:\n\n```text\nORCA orchestration worker-start --task --retry-of --worktree --agent --json\n```\n\nAfter three consecutive failures for one Task, its dispatch context\ncircuit-breaks and the Task is failed. Do not route around that boundary with a\nnew Run or an unrelated Dispatch.\n\nFor `outcome_unknown`, inspect first, then make an explicit choice:\n\n```text\nORCA orchestration worker-stop --dispatch --json\nORCA orchestration worker-abandon --dispatch --json\n```\n\n`worker-stop` closes only the exact proven supervised agent terminal. It never\ndeletes the worktree, setup terminal, configured tabs, or unrelated processes.\n`worker-abandon` fences orchestration while accepting that resources may remain\nlive; it performs no remote, process, or filesystem action. When it settles a\nworker, it retains an owned terminal by the same rule as `worker-retain`, so Orca\nstops owing its release; a release already committed (`releasing`,\n`release_unknown`) is left as it is. An already-settled worker is left untouched.\n\nTo cancel a Task, settle its worker with `worker-stop` or `worker-abandon`, then\nrecord the reason. A cancelled Task is `failed`, so its dependents stay blocked\nuntil a `--retry-of` replacement completes it:\n\n```text\nORCA orchestration task-update --id --status failed --result cancelled --json\n```\n\n## Retain and release\n\n```text\nORCA orchestration worker-retain --dispatch --json\nORCA orchestration worker-release --dispatch --json\n```\n\nRetain only when the user explicitly wants the settled terminal kept live.\nRelease works after succeeded and failed reports, archives readable output, and\ncloses only the exact terminal owned by that settled Dispatch. Replays may call\nrelease again safely. Reused, pre-existing, setup, coordinator, active,\nuser-taken-over, and unproven terminals are retained.\n\nA `worker-start` that failed before its agent was ready still owns the terminal\nit created. Its receipt names `worker-release`, and `worker-list` reports that\nrow as `reclaimable`; release it there rather than closing the terminal by hand.\n\nNever release because of timeout, TUI idle, heartbeat, status, question,\nescalation, or stale/rejected completion. If the receipt says `release_pending`\nor `release_unknown`, follow its exact recovery action. Never substitute\n`terminal close`.\n\n`orchestration reset` is destructive recovery. Do not run it during active\ncoordination unless the user explicitly abandons that state.\n\n\n\n# Worker contract\n\nThe injected preamble is authoritative. Copy its command rather than\nreconstructing flags. In particular, preserve the exact executable, worker\nhandle, Task ID, and Dispatch ID, and keep any other flag it carries (an older\nOrca host adds `--dispatch-capability`).\n\n## Heartbeat\n\nSend heartbeats only at the cadence required by the live preamble. Skip them\nwhile blocked inside `ask` or `check --wait`; those calls are liveness signals.\n\n```text\nORCA orchestration send --from --type heartbeat --subject \"alive\" --task-id --dispatch-id --phase \"\"\n```\n\nUse typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves\nliveness, never completion.\n\n## Ask and resume\n\nUse Orca `ask` whenever the coordinator must answer. Never open a local question\nTUI the coordinator cannot answer.\n\n```text\nORCA orchestration ask --from --question \"\" --options \",\" --timeout-ms 600000\n\nORCA orchestration ask --from --resume --timeout-ms 600000\n```\n\nA timeout or disconnect leaves the original question pending. Resume its\nmessage ID; do not create a duplicate question.\n\n## Reading coordinator follow-ups\n\nThe coordinator steers a running worker with `send --to dispatch:`. That\nenqueue is durable but does not interrupt you, so nothing arrives unless you\nlook:\n\n```text\nORCA orchestration check --terminal --json\n```\n\nRun it at each natural checkpoint — before starting a new file, after a test\nrun — and once more immediately before `worker_done`, so a redirect or a\ncancellation lands before the Task settles. `check` names its caller with\n`--terminal`, never `--from`. Stop checking after `worker_done`.\n\nIf `check` returns `consumer_fenced`, this process no longer owns its Dispatch:\nthe Attempt was re-attached to another worker or settled without you. Stop, do\nnot send `worker_done`, and do not retry the check. An empty `check` never means\nyou were replaced; `consumer_fenced` is the only way you learn that.\nIf `send` or `ask` returns `consumer_fenced`, the command ran from another\nparty's terminal (a coordinator or another worker); run it from your own terminal.\n\n## Escalation\n\nEscalate only before completion and only when the coordinator must intervene:\n\n```text\nORCA orchestration send --from --type escalation --subject \"Blocked: \" --body \"
\" --task-id --dispatch-id \n```\n\n## Completion\n\nSend exactly one terminal report. `--body` is three sentences: what changed,\nwhat was found, and what remains. Use `--outcome failed` when the requested work\nis not complete; never hide failure in prose or silently exit.\n\nAppend `--files-modified` or `--report-path` only when applicable, using actual\npaths. Do not send documentation placeholders as metadata.\n\n```text\nORCA orchestration send --from --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded\n```\n\nAfter `worker_done`, end the dispatched turn and idle. Do not poll, close your\nown terminal, or begin unrelated work. A later direct user instruction is new\nuser-owned work and must not reuse settled lifecycle IDs; a supervised follow-up\narrives with a fresh preamble and Task block.\n" // oxfmt-ignore -const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` when the execution host verifies its CLI version\nand model availability. OpenCode effort is unsupported. Omit the model to inherit\nits configuration; unsupported or unknown hosts refuse the override explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" +const ORCHESTRATION_COORDINATOR_LOOP_REFERENCE_MARKDOWN = "# Coordinator loop\n\nLoad this reference for expanded DAG waves, per-invocation launch preferences,\nsame-terminal reuse, or review ownership. The compact guide remains the source\nof truth for the loop order and completion boundary.\n\n## Ready waves\n\nCreate independent Tasks before the first wait. Encode only real dependencies,\nthen use the ready view as external memory:\n\n```text\nORCA orchestration task-create --spec \"\" --deps --json\nORCA orchestration task-list --ready --brief --json\n```\n\n`--brief` collapses whitespace and caps echoed specs at 160 characters;\n`spec_truncated` identifies shortened rows. Omit it when full specs are needed or\nwhen an older CLI rejects the flag. A nested worker must respect\n`nested_worker_depth_exceeded`; creating another Run does not reset depth.\n\n## Launch preferences\n\nFor a fresh Claude, Codex, Cursor, Antigravity, or Muse terminal, `--model`\naccepts an opaque provider model ID. Pass it only when the user named a model;\notherwise omit it so the worker inherits the user's configured agent default.\nAdd `--effort` only when that model supports it:\n\n```text\nORCA orchestration worker-start --task --worktree current --agent claude --model opus --effort high --json\nORCA orchestration worker-start --task --worktree current --agent muse --model muse-spark-1.3 --json\n```\n\nOpenCode also accepts `--model` in an existing worktree when the execution host\nverifies its CLI version and model availability. Creating a new worktree with an\nOpenCode model override is unsupported; use `--worktree current` or an existing\nworktree selector, or omit `--model`. OpenCode effort is unsupported. Omit the\nmodel to inherit its configuration; unsupported or unknown hosts refuse the\noverride explicitly.\n\n`--effort` requires `--model`; neither option combines with `--terminal`. A\nconnected worker server must advertise launch-preference support before Orca\nforwards either field. Compare `launch.requested` with `launch.effective`; never\nclaim a model or effort from requested arguments alone.\n\n## Reuse after settlement\n\nChoose the terminal's next owner before acknowledging the Delivery. When the\nsame exact agent has immediate follow-up work, recover the proven handle and\ntransfer cleanup ownership to the new Dispatch:\n\n```text\nORCA orchestration worker-show --dispatch --json\nORCA orchestration worker-start --task --terminal --json\n```\n\nOtherwise explicitly retain or release the settled worker. Do not leave it live\nonly to inspect output; archived output remains available through `worker-read`.\n\n## Review ownership\n\nA review-only `worker_done` authorizes synthesis of findings, not coordinator\nfile edits. Dispatch or hand off fixes unless the user explicitly assigned them\nto the coordinator. If the user's plan names a next owner, post-review fixes and\nPR preparation remain with that owner; the coordinator routes and synthesizes.\n" // oxfmt-ignore const ORCHESTRATION_LEGACY_CONTRACT_MIGRATION_REFERENCE_MARKDOWN = "# Legacy contract migration\n\nLoad this reference only for an authority label, adopted Run, compatibility or\nrecovery receipt, or explicit legacy takeover. A newly created attempt always\nuses the current grammar.\n\n## Authority labels\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported\n command printed with the message, using the same selected executable and\n arguments supplied by the original prompt.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,\n at-least-once cutover replay. Process it idempotently and acknowledge only\n through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or\n lifecycle mutation.\n- An unlabeled current message uses the current guide and grammar.\n\nAn explicitly selected current Run, attested current binding, current Dispatch,\nor federated attachment takes precedence over legacy fallback. A retained\nadoption record alone does not grant mutation authority. If liveness, principal\nownership, capability, or the exact legacy contract is unproven, degrade to\nread-only inspection and never fall back to local execution.\n\nAdoption preserves the live agent process, PTY/session, terminal handle,\ntab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or\nreplaces the worker and never revives the retired scheduler. Loss of lifecycle\nauthority does not invalidate the existing process, assignment, or filesystem\nwork. Exact recovery may restore the same PTY once in its original inactive\nbackground tab; it must not spawn, write, signal, stop, switch, focus, split, or\ninject a terminal.\n\n## Compatibility recovery\n\nWhen a compatibility response returns structured next-step arguments, execute\nthose exact arguments with the same selected CLI executable. Do not translate\nfrom memory, broaden the recipient, or retry as a current mutation unless the\nreceipt explicitly authorizes it.\n\nA pending ask, reply, final Dispatch settlement, and consuming check have\ndurable recovery identities. Heartbeat and escalation remain at-least-once\nacross a manual contract-boundary retry. If an ask may already have been\nanswered, run the exact non-consuming recovery check printed by Orca before\ncreating any new question. Never guess among identical question threads.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The\ninitial command commits the question, prints its exact\n`ask --resume ` command, and exits with launcher status `75`. Run\nthat exact resume after the launcher or update boundary. For an attested WSL\nlaunch, preserve the printed `orca-ide` executable and distro route. Older WSL\nworkers without launch proof remain lifecycle read-only even while their\nterminal and filesystem work continue.\n\n## Read-only inspection and takeover\n\nRead-only inspection does not consume mail:\n\n```text\nORCA orchestration run-list --json\nORCA orchestration run-show --id run_legacy_local --json\nORCA orchestration run-show --id --json\nORCA orchestration task-list --run --json\nORCA orchestration inbox --full --json\nORCA orchestration check --terminal --peek --format --json\nORCA terminal read --terminal --json\nORCA terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\n`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary\nRun whose objective is `Recovered orchestration work from a contract update`.\n\nOnly when the original coordinator is unavailable or cannot prove retained\nauthority may a new live coordinator take over from its own terminal:\n\n```text\nORCA orchestration run-use --id --takeover-legacy --json\nORCA orchestration check --run --json\n```\n\nTakeover binds the authenticated invoking terminal; `--from` cannot nominate\nanother coordinator. It fences only the old coordinator and moves pending mail\ninto current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.\nNever take over while the original coordinator is actively coordinating.\n\nDo not launch a replacement editor merely because Orca updated or authority is\nunclear. Keep the original worker as the only editor until a stable handoff\npoint, then use a fresh current Dispatch in a conflict-free placement.\n" diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index f69158114ac2..43674b9d7578 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -36,7 +36,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.', - '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires a verified execution-host CLI and available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', + '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. OMP accepts --model only; --effort is unsupported. Neither can combine with --terminal. OpenCode model selection requires an existing worktree, a verified execution-host CLI, and an available model; effort is unsupported. Other agents, including zcode, launch with the model from their own config.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts index 787a1f2e71b4..79f9ec707abb 100644 --- a/src/main/opencode/opencode-model-availability.test.ts +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -63,6 +63,17 @@ describe('OpenCode model catalog validation', () => { }) expect(await probeOpenCodeModelAvailability(options)).toBe(false) }) + it('refuses a truncated catalog even when its retained head contains the model', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: true, + stdout: options.model + '\n', + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) it('refuses WSL until its exact guest launch environment is available', async () => { expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( false diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts index 8cfdc260a45f..f3ec2b21d9f1 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-availability.ts @@ -49,6 +49,7 @@ export async function probeOpenCodeModelAvailability( return ( result.code === 0 && !result.timedOut && + !result.outputTruncated && result.stdout.split(/\r?\n/).some((line) => line === options.model) ) } catch { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index e18a0d2e26c4..481b96840a90 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -123,6 +123,17 @@ describe('orchestration worker launch preferences', () => { ).toThrow('cannot verify launch-time model selection') }) + it('explains the existing-worktree requirement even if a new-worktree host is verified', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'opencode', + model: 'opencode/fledge-alpha-free', + createsWorktree: true, + openCodeModelLaunchSupported: true + }) + ).toThrow('requires an existing worktree') + }) + it('supports an opaque OpenCode model on a verified legacy host', () => { expect( resolveWorkerLaunchPreferences({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts index 70a3a55cec8b..a5b12d7d46ad 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.ts @@ -53,6 +53,7 @@ export function createPendingWorkerLaunchReceipt(args: { export function resolveWorkerLaunchPreferences(args: { agent: TuiAgent openCodeModelLaunchSupported?: boolean + createsWorktree?: boolean model?: string effort?: string }): { @@ -69,6 +70,13 @@ export function resolveWorkerLaunchPreferences(args: { } } + if (args.agent === 'opencode' && args.createsWorktree) { + throw new OrchestrationError( + 'capability_unsupported', + 'OpenCode model selection requires an existing worktree. Use --worktree current or an existing worktree selector, or omit --model.' + ) + } + if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) { throw new OrchestrationError( 'capability_unsupported', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts index 25d0a7830518..b9e39abb859d 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-validation.ts @@ -80,6 +80,7 @@ export function prepareLocalWorkerStart(args: { return resolveWorkerStartAgent({ runtime, openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree, terminal: params.terminal, agent: params.agent, model: params.model, @@ -126,6 +127,7 @@ export function prepareFederationAttachmentWorkerStart(args: { return resolveWorkerStartAgent({ runtime, openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree, terminal: params.terminal, agent: params.agent, model: params.model, @@ -138,6 +140,7 @@ export function prepareFederationAttachmentWorkerStart(args: { function resolveWorkerStartAgent(args: { runtime: OrcaRuntimeService openCodeModelLaunchSupported?: boolean + createsWorktree: boolean terminal?: string agent?: string model?: string @@ -159,6 +162,7 @@ function resolveWorkerStartAgent(args: { launch: resolveWorkerLaunchPreferences({ agent, openCodeModelLaunchSupported: args.openCodeModelLaunchSupported, + createsWorktree: args.createsWorktree, model: args.model, effort: args.effort }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 3e5f717c382f..50fee27ac48b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -205,7 +205,10 @@ describe('orchestration new-worktree workers', () => { agent: 'opencode', model: 'opencode/fledge-alpha-free' }) - ).rejects.toMatchObject({ code: 'capability_unsupported' }) + ).rejects.toMatchObject({ + code: 'capability_unsupported', + message: expect.stringContaining('requires an existing worktree') + }) expect(probe).not.toHaveBeenCalled() expect(runtime.createManagedWorktree).not.toHaveBeenCalled() expect(runtime.createTerminal).not.toHaveBeenCalled() @@ -230,7 +233,10 @@ describe('orchestration new-worktree workers', () => { }) await expect( prepareFederationWorkerLaunchOnHost({ runtime, params, createsWorktree: true }) - ).rejects.toMatchObject({ code: 'capability_unsupported' }) + ).rejects.toMatchObject({ + code: 'capability_unsupported', + message: expect.stringContaining('requires an existing worktree') + }) expect(probe).not.toHaveBeenCalled() expect(runtime.createTerminal).not.toHaveBeenCalled() expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() From 8b2d41e003b6b9cfcc2dfabe51ee743a2be34964 Mon Sep 17 00:00:00 2001 From: OpenCode campaign Date: Sun, 4 Oct 2026 14:15:53 -0700 Subject: [PATCH 92/93] Use a template literal in truncated-catalog coverage --- src/main/opencode/opencode-model-availability.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts index 79f9ec707abb..5a3d1e8267ae 100644 --- a/src/main/opencode/opencode-model-availability.test.ts +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -68,7 +68,7 @@ describe('OpenCode model catalog validation', () => { code: 0, timedOut: false, outputTruncated: true, - stdout: options.model + '\n', + stdout: `${options.model}\n`, stderr: '', signal: null }) From a3f7e81da0eb13ed48130f952f7355be8477696f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:21:24 -0700 Subject: [PATCH 93/93] Refuse unfinished OpenCode model catalogs --- .../opencode-model-availability.test.ts | 22 +++++++++++++++++++ .../opencode/opencode-model-availability.ts | 1 + 2 files changed, 23 insertions(+) diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts index 5a3d1e8267ae..f8b3fdf552c6 100644 --- a/src/main/opencode/opencode-model-availability.test.ts +++ b/src/main/opencode/opencode-model-availability.test.ts @@ -74,6 +74,28 @@ describe('OpenCode model catalog validation', () => { }) expect(await probeOpenCodeModelAvailability(options)).toBe(false) }) + it('refuses the unterminated catalog shape captured from OpenCode 2.0.16', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: false, + stdout: `${options.model}\npriv`, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(false) + }) + it.each(['\n', '\r\n'])('accepts a complete catalog with %j line endings', async (ending) => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + timedOut: false, + outputTruncated: false, + stdout: `${options.model}${ending}`, + stderr: '', + signal: null + }) + expect(await probeOpenCodeModelAvailability(options)).toBe(true) + }) it('refuses WSL until its exact guest launch environment is available', async () => { expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( false diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-availability.ts index f3ec2b21d9f1..ce507de99694 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-availability.ts @@ -50,6 +50,7 @@ export async function probeOpenCodeModelAvailability( result.code === 0 && !result.timedOut && !result.outputTruncated && + result.stdout.endsWith('\n') && result.stdout.split(/\r?\n/).some((line) => line === options.model) ) } catch {