diff --git a/package.json b/package.json index 5c796d079cd..816544de822 100644 --- a/package.json +++ b/package.json @@ -55,12 +55,12 @@ "tc:cli": "pnpm run typecheck:cli", "tc:web": "pnpm run typecheck:web", "tc": "pnpm run typecheck", - "typecheck:node": "tsc --noEmit -p config/tsconfig.node.json", - "typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json", - "typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json", - "typecheck:e2e": "tsc --noEmit -p config/tsconfig.e2e.json", + "typecheck:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json", + "typecheck:cli": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.cli.json", + "typecheck:web": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.tc.web.json", + "typecheck:e2e": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.e2e.json", "typecheck": "node config/scripts/run-typecheck-projects-in-parallel.mjs", - "typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false", + "typecheck:tsc:node": "node node_modules/typescript/bin/tsc --noEmit -p config/tsconfig.node.json --composite false", "typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false", "typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false", "typecheck:tsc": "tsc --noEmit -p config/tsconfig.node.json --composite false && tsc --noEmit -p config/tsconfig.cli.json --composite false && tsc --noEmit -p config/tsconfig.web.json --composite false", diff --git a/skill-guides/orchestration/references/coordinator-loop.md b/skill-guides/orchestration/references/coordinator-loop.md index 24dd27d82a1..48f4a799d30 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, or Cursor terminal, `--model` accepts an opaque +For a fresh Claude, Codex, Cursor, or Antigravity 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 21813b79a17..d523cdf56d7 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, or Cursor 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, 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" // 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, or Cursor 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, 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" // 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 1608a586a15..f8ace033934 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, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', + '--model supports Claude, Codex, Cursor, and Antigravity 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/agent-trust-presets.test.ts b/src/main/agent-trust-presets.test.ts index f5377be86c4..5e7521ae468 100644 --- a/src/main/agent-trust-presets.test.ts +++ b/src/main/agent-trust-presets.test.ts @@ -38,8 +38,12 @@ vi.mock('node:os', async () => { } }) -const { markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted } = - await import('./agent-trust-presets') +const { + markAntigravityWorkspaceTrusted, + markCodexProjectTrusted, + markCopilotFolderTrusted, + markCursorWorkspaceTrusted +} = await import('./agent-trust-presets') const { runExclusivelyForCodexTrustConfig } = await import('./codex/codex-trust-config-mutation-queue') @@ -138,6 +142,75 @@ describe('markCopilotFolderTrusted', () => { }) }) +describe('markAntigravityWorkspaceTrusted', () => { + it('appends the workspace to trustedWorkspaces in ~/.gemini/antigravity-cli/settings.json', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-agy-ws-')) + try { + markAntigravityWorkspaceTrusted(workspace) + const configPath = join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json') + expect(existsSync(configPath)).toBe(true) + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')) + expect(Array.isArray(parsed.trustedWorkspaces)).toBe(true) + expect(parsed.trustedWorkspaces).toHaveLength(1) + expect(parsed.trustedWorkspaces[0]).toBe(realpathSync(workspace)) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + // Why: the same settings.json also carries model, permissions and toolPermission. A + // clobbering write here would silently reset the user's agy configuration. + it('preserves sibling settings keys and dedups an already-trusted workspace', () => { + const workspace = mkdtempSync(join(tmpdir(), 'orca-agy-ws-')) + const realpath = realpathSync(workspace) + try { + mkdirSync(join(testState.fakeHomeDir, '.gemini', 'antigravity-cli'), { recursive: true }) + writeFileSync( + join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'), + JSON.stringify({ + agentMode: 'accept-edits', + model: 'gemini-3.8-flash', + trustedWorkspaces: [realpath] + }) + ) + markAntigravityWorkspaceTrusted(workspace) + const parsed = JSON.parse( + readFileSync( + join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'), + 'utf-8' + ) + ) + expect(parsed.agentMode).toBe('accept-edits') + expect(parsed.model).toBe('gemini-3.8-flash') + expect(parsed.trustedWorkspaces).toHaveLength(1) + } finally { + rmSync(workspace, { recursive: true, force: true }) + } + }) + + // Why: agy's trust is exact-path, not inherited — a parent entry does not cover a child, + // which is what makes the per-worktree preflight necessary at all. + it('adds a child worktree even when its parent is already trusted', () => { + const parent = mkdtempSync(join(tmpdir(), 'orca-agy-parent-')) + const child = join(parent, 'child-worktree') + try { + mkdirSync(child, { recursive: true }) + markAntigravityWorkspaceTrusted(parent) + markAntigravityWorkspaceTrusted(child) + const parsed = JSON.parse( + readFileSync( + join(testState.fakeHomeDir, '.gemini', 'antigravity-cli', 'settings.json'), + 'utf-8' + ) + ) + expect(parsed.trustedWorkspaces).toHaveLength(2) + expect(parsed.trustedWorkspaces).toContain(realpathSync(child)) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) +}) + describe('markCodexProjectTrusted', () => { // Why (#16441): a hook install/grant holds this file across an awaited // app-server session; an unqueued write here lands inside its diff --git a/src/main/agent-trust-presets.ts b/src/main/agent-trust-presets.ts index 16c728eba55..30e2ba487f3 100644 --- a/src/main/agent-trust-presets.ts +++ b/src/main/agent-trust-presets.ts @@ -6,7 +6,7 @@ import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths' import { upsertProjectTrustLevel } from './codex/config-toml-trust' import { runExclusivelyForCodexTrustConfig } from './codex/codex-trust-config-mutation-queue' -export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' +export type AgentTrustPreset = 'cursor' | 'copilot' | 'codex' | 'antigravity' /** * Pre-mark a workspace as trusted for cursor-agent, GitHub Copilot CLI, or @@ -75,9 +75,9 @@ export function markCopilotFolderTrusted(workspacePath: string): void { try { if (existsSync(configPath)) { const raw = readFileSync(configPath, 'utf-8') - const parsed = JSON.parse(raw) - if (parsed && typeof parsed === 'object') { - config = parsed as Record + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = Object.fromEntries(Object.entries(parsed)) } } } catch { @@ -101,6 +101,59 @@ export function markCopilotFolderTrusted(workspacePath: string): void { writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) } +/** + * The Antigravity CLI (agy) keeps its trusted workspaces in + * ~/.gemini/antigravity-cli/settings.json under `trustedWorkspaces`, a flat + * array of absolute paths in native OS form. + * + * Verified empirically against agy 1.2.7 on Windows: accepting the CLI's + * "Do you trust the contents of this project?" prompt for a freshly created + * worktree appended exactly that worktree's path to this array. Note this is + * NOT ~/.gemini/trustedFolders.json — that file belongs to the Gemini CLI and + * agy does not consult it. + * + * Trust is exact-path and NOT inherited by subdirectories: `C:\Users\` + * was already present in the array, yet launching agy in a descendant still + * raised the prompt and appended the descendant separately. Every new child + * worktree therefore needs its own entry, which is precisely what this + * per-worktree preflight provides. + * + * We append in-place so the sibling keys in the same file (model, permissions, + * toolPermission, agentMode, …) survive untouched. + */ +export function markAntigravityWorkspaceTrusted(workspacePath: string): void { + const absPath = canonicalize(workspacePath) + const configDir = join(homedir(), '.gemini', 'antigravity-cli') + const configPath = join(configDir, 'settings.json') + let config: Record = {} + try { + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed && typeof parsed === 'object') { + config = parsed as Record + } + } + } catch { + // Why: a corrupted settings.json is the user's to fix — refuse to + // overwrite it from this side-effect path. agy rewrites the file itself + // once the user accepts the trust prompt manually. + return + } + const existing = Array.isArray(config.trustedWorkspaces) ? config.trustedWorkspaces : [] + const normalizedExisting = existing.map((entry) => + typeof entry === 'string' ? canonicalize(entry) : null + ) + if (normalizedExisting.includes(absPath)) { + return + } + config.trustedWorkspaces = [...existing.filter((e) => typeof e === 'string'), absPath] + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + writeFileAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`) +} + /** * Codex stores project trust in ~/.codex/config.toml under: * [projects.""] diff --git a/src/main/ipc/agent-trust.ts b/src/main/ipc/agent-trust.ts index 9e18d1c59cc..34f39570ccb 100644 --- a/src/main/ipc/agent-trust.ts +++ b/src/main/ipc/agent-trust.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { type AgentTrustPreset, + markAntigravityWorkspaceTrusted, markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted @@ -43,6 +44,8 @@ export function registerAgentTrustHandlers(): void { markCopilotFolderTrusted(args.workspacePath) } else if (args.preset === 'codex') { markCodexProjectTrusted(args.workspacePath) + } else if (args.preset === 'antigravity') { + markAntigravityWorkspaceTrusted(args.workspacePath) } } catch { // Best-effort: see Why above. The user can still accept the trust diff --git a/src/main/remote-agent-trust-presets.ts b/src/main/remote-agent-trust-presets.ts index b5eaaf6d2c0..a9ccb382c09 100644 --- a/src/main/remote-agent-trust-presets.ts +++ b/src/main/remote-agent-trust-presets.ts @@ -27,6 +27,12 @@ export async function markRemoteAgentWorkspaceTrusted(args: { } else if (args.preset === 'copilot') { await markRemoteCopilotFolderTrusted(fsProvider, home, workspacePath) } + // KNOWN GAP: 'antigravity' is deliberately absent. The local preset writes + // ~/.gemini/antigravity-cli/settings.json, and the remote equivalent has not been verified + // against an SSH execution host, so an agy worker launched over SSH still raises its + // first-launch trust prompt and will stall at agent_readiness. Falling through silently + // matches the pre-existing behaviour for agy; it is recorded here rather than left as an + // unexplained omission. Mirror markRemoteCopilotFolderTrusted once it can be tested. } async function resolveRemoteHome(connectionId: string): Promise { diff --git a/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts new file mode 100644 index 00000000000..c893623a18a --- /dev/null +++ b/src/main/runtime/agent-prompt-submission-runtime-hook-and-generation.test.ts @@ -0,0 +1,506 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_PROMPT_BRACKETED_PASTE_END, + buildAgentPromptPasteBytes, + getAgentPromptSubmitDelayMs +} from '../../shared/agent-prompt-injection' +import { + AGENT_PROMPT_TEST_WORKTREE_PATH, + createAgentPromptSubmissionRuntime +} from './agent-prompt-submission-runtime-test-fixture' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +const createPromptRuntime = createAgentPromptSubmissionRuntime + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]) +})) + +describe('agent prompt submission runtime hook and generation cases', () => { + afterEach(() => vi.useRealTimers()) + + async function createHookOnlyPromptRuntime( + hook: { + state: 'done' | 'working' + stateStartedAt: number + }, + launchAgent: 'antigravity' | 'kimi' | 'codex' = 'kimi' + ): Promise<{ + runtime: OrcaRuntimeService + handle: string + writes: string[] + }> { + let handle = '' + const writes: string[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: hook.state, + prompt: '', + agentType: launchAgent, + connectionId: null, + // Why: every hook ping refreshes receivedAt, including same-state tool pings. + receivedAt: Date.now(), + stateStartedAt: hook.stateStartedAt + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent + }) + ).handle + return { runtime, handle, writes } + } + + it('accepts a hook working status with no window and no title coverage', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 } + const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + vi.setSystemTime(3_000) + hook.state = 'working' + hook.stateStartedAt = 3_000 + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('settles an Antigravity prompt when PreInvocation starts a new hook turn', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 } + const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'antigravity') + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + vi.setSystemTime(3_000) + hook.state = 'working' + hook.stateStartedAt = 3_000 + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + acceptQueued: true, + requestId: 'antigravity-pre-invocation', + observationTimeoutMs: 20_000 + }) + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ + prompt: { + provider: 'antigravity', + observation: 'supported', + stages: ['input_accepted', 'turn_started'] + } + }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + // Why: same-state pings keep refreshing receivedAt on a turn that started before the prompt; + // only the pinned stateStartedAt separates that from a turn this prompt started. + it('does not accept a hook row refreshed without a new working turn', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { runtime, handle, writes } = await createHookOnlyPromptRuntime({ + state: 'working', + stateStartedAt: 1_000 + }) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('reserves a hook-only turn start for the oldest queued prompt receipt', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook = { state: 'working' as const, stateStartedAt: 1_000 } + const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') + + const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + acceptQueued: true, + requestId: 'hook-queued-first', + observationTimeoutMs: 0 + }) + await vi.runAllTimersAsync() + const first = await firstPromise + expect(first.prompt?.stages).toEqual(['input_accepted']) + + const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 20_000) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + hook.stateStartedAt = Date.now() + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + acceptQueued: true, + requestId: 'hook-queued-second', + observationTimeoutMs: 500 + }) + await vi.runAllTimersAsync() + + await expect(firstObserved).resolves.toMatchObject({ + stages: ['input_accepted', 'turn_started'] + }) + const second = await secondPromise + expect(second).toMatchObject({ + prompt: { stages: ['input_accepted'] } + }) + + const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000) + hook.stateStartedAt += 1 + await vi.advanceTimersByTimeAsync(50) + + await expect(secondObserved).resolves.toMatchObject({ + stages: ['input_accepted', 'turn_started'] + }) + }) + + it('does not write Enter after the PTY generation changes during settlement', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') + + await vi.advanceTimersByTimeAsync(0) + expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-prompt') + ) + await vi.runAllTimersAsync() + + await rejected + expect(writes).not.toContain('\r') + }) + + it('does not reuse explicit permission status across a provider generation reset', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"waiting","agentType":"aider"}\x07', + Date.now() + ) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + 0 + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + await vi.advanceTimersByTimeAsync(0) + + expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) + controller.abort() + await vi.runAllTimersAsync() + await rejected + }) + + it('does not reuse output-only permission across a provider generation reset', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + sequenceAtSpawnStart + ) + + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + await vi.runAllTimersAsync() + + await expect(submission).resolves.toMatchObject({ accepted: true }) + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('fails closed when new bytes race a reset after old permission output', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'continued' }, + 0 + ) + runtime.onPtyData( + 'pty-prompt', + 'Permission required\nAllow once\nAllow always\nReject\n', + Date.now() + ) + const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') + runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now()) + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + sequenceAtSpawnStart + ) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( + 'agent_prompt_blocked' + ) + expect(writes).toEqual([]) + }) + + it('reports permission reached after the first Enter as blocked', async () => { + vi.useFakeTimers() + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) + } + }) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') + const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') + + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) + + it('serializes concurrent prompt submissions within one PTY generation', async () => { + vi.useFakeTimers() + let enterCount = 0 + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + enterCount += 1 + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) + } + }) + + const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') + const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') + await vi.runAllTimersAsync() + await Promise.all([first, second]) + + const firstPaste = writes.findIndex((data) => data.includes('first prompt')) + const firstEnter = writes.indexOf('\r', firstPaste + 1) + const secondPaste = writes.findIndex((data) => data.includes('second prompt')) + const secondEnter = writes.indexOf('\r', secondPaste + 1) + expect(firstPaste).toBeGreaterThanOrEqual(0) + expect(firstEnter).toBeGreaterThan(firstPaste) + expect(secondPaste).toBeGreaterThan(firstEnter) + expect(secondEnter).toBeGreaterThan(secondPaste) + expect(enterCount).toBe(2) + }) + + it('reserves a lifecycle transition for only one queued prompt receipt', async () => { + vi.useFakeTimers() + const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + + const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { + acceptQueued: true, + requestId: 'queued-first', + observationTimeoutMs: 0 + }) + await vi.runAllTimersAsync() + const first = await firstPromise + const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { + acceptQueued: true, + requestId: 'queued-second', + observationTimeoutMs: 0 + }) + await vi.runAllTimersAsync() + const second = await secondPromise + + runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07\x1b]0;Codex working\x07', Date.now()) + const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 1_000) + await vi.runAllTimersAsync() + const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000) + await vi.runAllTimersAsync() + + await expect(firstObserved).resolves.toMatchObject({ + stages: ['input_accepted', 'turn_started'] + }) + await expect(secondObserved).resolves.toMatchObject({ + stages: ['input_accepted'] + }) + }) + + it('does not queue a replacement generation behind an obsolete submission', async () => { + vi.useFakeTimers() + let releaseFirst!: () => void + let firstWriteReached!: () => void + const firstWrite = new Promise((resolve) => { + firstWriteReached = resolve + }) + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { + if (data === '\r') { + runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) + } + }) + + const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { + beforeWrite: async () => { + firstWriteReached() + await firstGate + } + }) + await firstWrite + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + 0 + ) + + const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') + await vi.runAllTimersAsync() + await expect(replacement).resolves.toMatchObject({ accepted: true }) + expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) + + releaseFirst() + await expect(first).rejects.toThrow('terminal_handle_stale') + }) + + it('does not close a partial paste after the PTY generation changes', async () => { + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + let writeChecks = 0 + + const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { + beforeWrite: () => { + writeChecks += 1 + if (writeChecks === 2) { + runtime.synchronizePtyOutputSequenceFromProvider( + 'pty-prompt', + { value: 0, generation: 'reset' }, + runtime.getPtyOutputSequence('pty-prompt') + ) + } + } + }) + + await expect(submission).rejects.toThrow('terminal_handle_stale') + expect(writes).toHaveLength(1) + expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END) + }) + + it('does not send delayed Enter after cancellation during settlement', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + + await vi.advanceTimersByTimeAsync(0) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(0) + }) + + it('does not send another Enter after cancellation during verification', async () => { + vi.useFakeTimers() + const controller = new AbortController() + const { runtime, handle, writes } = await createPromptRuntime(() => undefined) + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + signal: controller.signal + }) + const rejected = expect(submission).rejects.toThrow('request_aborted') + + // Why compute it: the submit delay now follows the payload size and the executing host, + // so a hardcoded number aborts before the Enter on some lanes. + await vi.advanceTimersByTimeAsync( + getAgentPromptSubmitDelayMs( + process.platform, + Buffer.byteLength(buildAgentPromptPasteBytes('review this'), 'utf8') + ) + ) + // Why: pin the phase boundary so drift fails here instead of as an empty post-abort array. + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + controller.abort() + await vi.runAllTimersAsync() + + await rejected + expect(writes.filter((data) => data === '\r')).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-runtime.test.ts b/src/main/runtime/agent-prompt-submission-runtime.test.ts index a79a8993191..873863ada75 100644 --- a/src/main/runtime/agent-prompt-submission-runtime.test.ts +++ b/src/main/runtime/agent-prompt-submission-runtime.test.ts @@ -1,9 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - AGENT_PROMPT_BRACKETED_PASTE_END, - buildAgentPromptPasteBytes, - getAgentPromptSubmitDelayMs -} from '../../shared/agent-prompt-injection' +import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection' import { AGENT_PROMPT_TEST_WORKTREE_PATH, createAgentPromptSubmissionRuntime @@ -470,434 +466,4 @@ describe('agent prompt submission runtime', () => { // Why: hook rows reach the runtime through this provider, which has no window and no OSC title — // the same path a headless `orca serve` host and a minimized desktop window take. - async function createHookOnlyPromptRuntime( - hook: { - state: 'done' | 'working' - stateStartedAt: number - }, - launchAgent: 'kimi' | 'codex' = 'kimi' - ): Promise<{ - runtime: OrcaRuntimeService - handle: string - writes: string[] - }> { - let handle = '' - const writes: string[] = [] - const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey: 'prompt-pane', - terminalHandle: handle, - state: hook.state, - prompt: '', - agentType: launchAgent, - connectionId: null, - // Why: every hook ping refreshes receivedAt, including same-state tool pings. - receivedAt: Date.now(), - stateStartedAt: hook.stateStartedAt - } - ] - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - handle = ( - await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { - launchAgent - }) - ).handle - return { runtime, handle, writes } - } - - it('accepts a hook working status with no window and no title coverage', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const hook = { state: 'done' as 'done' | 'working', stateStartedAt: 1_000 } - const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - if (data === '\r') { - vi.setSystemTime(3_000) - hook.state = 'working' - hook.stateStartedAt = 3_000 - } - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - await vi.runAllTimersAsync() - - await expect(submission).resolves.toMatchObject({ accepted: true }) - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - }) - - // Why: same-state pings keep refreshing receivedAt on a turn that started before the prompt; - // only the pinned stateStartedAt separates that from a turn this prompt started. - it('does not accept a hook row refreshed without a new working turn', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const { runtime, handle, writes } = await createHookOnlyPromptRuntime({ - state: 'working', - stateStartedAt: 1_000 - }) - - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') - await vi.runAllTimersAsync() - - await rejected - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - }) - - it('reserves a hook-only turn start for the oldest queued prompt receipt', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const hook = { state: 'working' as const, stateStartedAt: 1_000 } - const { runtime, handle, writes } = await createHookOnlyPromptRuntime(hook, 'codex') - - const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { - acceptQueued: true, - requestId: 'hook-queued-first', - observationTimeoutMs: 0 - }) - await vi.runAllTimersAsync() - const first = await firstPromise - expect(first.prompt?.stages).toEqual(['input_accepted']) - - const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 20_000) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - if (data === '\r') { - hook.stateStartedAt = Date.now() - } - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { - acceptQueued: true, - requestId: 'hook-queued-second', - observationTimeoutMs: 500 - }) - await vi.runAllTimersAsync() - - await expect(firstObserved).resolves.toMatchObject({ - stages: ['input_accepted', 'turn_started'] - }) - const second = await secondPromise - expect(second).toMatchObject({ - prompt: { stages: ['input_accepted'] } - }) - - const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000) - hook.stateStartedAt += 1 - await vi.advanceTimersByTimeAsync(50) - - await expect(secondObserved).resolves.toMatchObject({ - stages: ['input_accepted', 'turn_started'] - }) - }) - - it('does not write Enter after the PTY generation changes during settlement', async () => { - vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - const rejected = expect(submission).rejects.toThrow('terminal_handle_stale') - - await vi.advanceTimersByTimeAsync(0) - expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - runtime.getPtyOutputSequence('pty-prompt') - ) - await vi.runAllTimersAsync() - - await rejected - expect(writes).not.toContain('\r') - }) - - it('does not reuse explicit permission status across a provider generation reset', async () => { - vi.useFakeTimers() - const controller = new AbortController() - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'continued' }, - 0 - ) - runtime.onPtyData( - 'pty-prompt', - '\x1b]9999;{"state":"waiting","agentType":"aider"}\x07', - Date.now() - ) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - 0 - ) - - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { - signal: controller.signal - }) - const rejected = expect(submission).rejects.toThrow('request_aborted') - await vi.advanceTimersByTimeAsync(0) - - expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true) - controller.abort() - await vi.runAllTimersAsync() - await rejected - }) - - it('does not reuse output-only permission across a provider generation reset', async () => { - vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) - } - }) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'continued' }, - 0 - ) - runtime.onPtyData( - 'pty-prompt', - 'Permission required\nAllow once\nAllow always\nReject\n', - Date.now() - ) - const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - sequenceAtSpawnStart - ) - - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - await vi.runAllTimersAsync() - - await expect(submission).resolves.toMatchObject({ accepted: true }) - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - }) - - it('fails closed when new bytes race a reset after old permission output', async () => { - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'continued' }, - 0 - ) - runtime.onPtyData( - 'pty-prompt', - 'Permission required\nAllow once\nAllow always\nReject\n', - Date.now() - ) - const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt') - runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now()) - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - sequenceAtSpawnStart - ) - - await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow( - 'agent_prompt_blocked' - ) - expect(writes).toEqual([]) - }) - - it('reports permission reached after the first Enter as blocked', async () => { - vi.useFakeTimers() - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now()) - } - }) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this') - const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked') - - await vi.runAllTimersAsync() - - await rejected - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - }) - - it('serializes concurrent prompt submissions within one PTY generation', async () => { - vi.useFakeTimers() - let enterCount = 0 - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - enterCount += 1 - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) - runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now()) - } - }) - - const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt') - const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt') - await vi.runAllTimersAsync() - await Promise.all([first, second]) - - const firstPaste = writes.findIndex((data) => data.includes('first prompt')) - const firstEnter = writes.indexOf('\r', firstPaste + 1) - const secondPaste = writes.findIndex((data) => data.includes('second prompt')) - const secondEnter = writes.indexOf('\r', secondPaste + 1) - expect(firstPaste).toBeGreaterThanOrEqual(0) - expect(firstEnter).toBeGreaterThan(firstPaste) - expect(secondPaste).toBeGreaterThan(firstEnter) - expect(secondEnter).toBeGreaterThan(secondPaste) - expect(enterCount).toBe(2) - }) - - it('reserves a lifecycle transition for only one queued prompt receipt', async () => { - vi.useFakeTimers() - const { runtime, handle } = await createAgentPromptSubmissionRuntime(() => undefined, 'codex') - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) - - const firstPromise = runtime.sendTerminalAgentPrompt(handle, 'first prompt', { - acceptQueued: true, - requestId: 'queued-first', - observationTimeoutMs: 0 - }) - await vi.runAllTimersAsync() - const first = await firstPromise - const secondPromise = runtime.sendTerminalAgentPrompt(handle, 'second prompt', { - acceptQueued: true, - requestId: 'queued-second', - observationTimeoutMs: 0 - }) - await vi.runAllTimersAsync() - const second = await secondPromise - - runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07\x1b]0;Codex working\x07', Date.now()) - const firstObserved = runtime.observeTerminalAgentPrompt(handle, first.prompt!, 1_000) - await vi.runAllTimersAsync() - const secondObserved = runtime.observeTerminalAgentPrompt(handle, second.prompt!, 1_000) - await vi.runAllTimersAsync() - - await expect(firstObserved).resolves.toMatchObject({ - stages: ['input_accepted', 'turn_started'] - }) - await expect(secondObserved).resolves.toMatchObject({ - stages: ['input_accepted'] - }) - }) - - it('does not queue a replacement generation behind an obsolete submission', async () => { - vi.useFakeTimers() - let releaseFirst!: () => void - let firstWriteReached!: () => void - const firstWrite = new Promise((resolve) => { - firstWriteReached = resolve - }) - const firstGate = new Promise((resolve) => { - releaseFirst = resolve - }) - const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => { - if (data === '\r') { - runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now()) - } - }) - - const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', { - beforeWrite: async () => { - firstWriteReached() - await firstGate - } - }) - await firstWrite - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - 0 - ) - - const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt') - await vi.runAllTimersAsync() - await expect(replacement).resolves.toMatchObject({ accepted: true }) - expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true) - - releaseFirst() - await expect(first).rejects.toThrow('terminal_handle_stale') - }) - - it('does not close a partial paste after the PTY generation changes', async () => { - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - let writeChecks = 0 - - const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), { - beforeWrite: () => { - writeChecks += 1 - if (writeChecks === 2) { - runtime.synchronizePtyOutputSequenceFromProvider( - 'pty-prompt', - { value: 0, generation: 'reset' }, - runtime.getPtyOutputSequence('pty-prompt') - ) - } - } - }) - - await expect(submission).rejects.toThrow('terminal_handle_stale') - expect(writes).toHaveLength(1) - expect(writes[0]).toContain(AGENT_PROMPT_BRACKETED_PASTE_END) - }) - - it('does not send delayed Enter after cancellation during settlement', async () => { - vi.useFakeTimers() - const controller = new AbortController() - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { - signal: controller.signal - }) - const rejected = expect(submission).rejects.toThrow('request_aborted') - - await vi.advanceTimersByTimeAsync(0) - controller.abort() - await vi.runAllTimersAsync() - - await rejected - expect(writes.filter((data) => data === '\r')).toHaveLength(0) - }) - - it('does not send another Enter after cancellation during verification', async () => { - vi.useFakeTimers() - const controller = new AbortController() - const { runtime, handle, writes } = await createPromptRuntime(() => undefined) - const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { - signal: controller.signal - }) - const rejected = expect(submission).rejects.toThrow('request_aborted') - - // Why compute it: the submit delay now follows the payload size and the executing host, - // so a hardcoded number aborts before the Enter on some lanes. - await vi.advanceTimersByTimeAsync( - getAgentPromptSubmitDelayMs( - process.platform, - Buffer.byteLength(buildAgentPromptPasteBytes('review this'), 'utf8') - ) - ) - // Why: pin the phase boundary so drift fails here instead of as an empty post-abort array. - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - controller.abort() - await vi.runAllTimersAsync() - - await rejected - expect(writes.filter((data) => data === '\r')).toHaveLength(1) - }) - }) diff --git a/src/main/runtime/agent-prompt-submission-verification.test.ts b/src/main/runtime/agent-prompt-submission-verification.test.ts index 3009289f3c4..aea3ffee9ef 100644 --- a/src/main/runtime/agent-prompt-submission-verification.test.ts +++ b/src/main/runtime/agent-prompt-submission-verification.test.ts @@ -4,6 +4,7 @@ import { AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS, type AgentPromptActivity, isAgentPromptStalledError, + isTerminalSendSettlementAgent, readAgentPromptWaitText, resolveAgentPromptEffectTimeoutMs, verifyAgentPromptSubmission @@ -292,12 +293,20 @@ describe('agent prompt submission verification', () => { }) it('gives hook-observed agents the longer effect window', () => { + expect(resolveAgentPromptEffectTimeoutMs('antigravity')).toBe( + AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS + ) expect(resolveAgentPromptEffectTimeoutMs('codex')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs('kimi')).toBe(AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs('claude')).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) expect(resolveAgentPromptEffectTimeoutMs(null)).toBe(AGENT_PROMPT_EFFECT_TIMEOUT_MS) }) + it('uses Antigravity PreInvocation hooks to settle prompt receipts', () => { + expect(isTerminalSendSettlementAgent('antigravity')).toBe(true) + expect(isTerminalSendSettlementAgent('gemini')).toBe(false) + }) + it('recognizes a stalled verdict from a message or a relayed error code', () => { expect(isAgentPromptStalledError(new Error('agent_prompt_stalled'))).toBe(true) expect(isAgentPromptStalledError({ code: 'agent_prompt_stalled' })).toBe(true) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 39dd8fa6c4e..ad7095abddc 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -5,7 +5,7 @@ import type { TuiAgent } from '../../shared/tui-agent' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 -const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['codex', 'kimi']) +const HOOK_OBSERVED_TURN_START_AGENTS = new Set(['antigravity', 'codex', 'kimi']) /** The prompt bytes are written before verification, so this only ever means "not observed". */ export const AGENT_PROMPT_STALLED_ERROR = 'agent_prompt_stalled' @@ -53,8 +53,8 @@ export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undef /** Only these providers expose a turn-start signal Orca can settle a prompt receipt against. */ export function isTerminalSendSettlementAgent( agent: TuiAgent | null | undefined -): agent is 'claude' | 'codex' { - return agent === 'claude' || agent === 'codex' +): agent is 'antigravity' | 'claude' | 'codex' { + return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } export function isAgentPromptStalledError(error: unknown): boolean { diff --git a/src/main/runtime/agent-transcript-pane-test-harness.ts b/src/main/runtime/agent-transcript-pane-test-harness.ts index 4345e98fd93..5f0c20267d9 100644 --- a/src/main/runtime/agent-transcript-pane-test-harness.ts +++ b/src/main/runtime/agent-transcript-pane-test-harness.ts @@ -1,6 +1,7 @@ // One pane builder for every suite that replays a captured agent transcript through the runtime. import { vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' +import type { TuiAgent } from '../../shared/tui-agent' const TRANSCRIPT_PANE_LEAF_ID = '11111111-1111-4111-8111-111111111111' const TRANSCRIPT_PANE_TAB_ID = 'tab-1' @@ -11,6 +12,7 @@ export type TranscriptPaneOptions = { paneTitle: string foregroundProcess: string | null data: string + launchAgent?: TuiAgent /** Set for a pane whose PTY lives on an SSH host or WSL distro rather than locally. */ connectionId?: string /** Simulates a PTY controller whose foreground probe never settles. */ @@ -71,6 +73,14 @@ export async function createTranscriptPane( } ] }) + if (options.launchAgent) { + runtime.registerPty(TRANSCRIPT_PANE_PTY_ID, TRANSCRIPT_PANE_WORKTREE_ID, null, { + tabId: TRANSCRIPT_PANE_TAB_ID, + leafId: TRANSCRIPT_PANE_LEAF_ID, + incarnationId: 'inc-1', + agentLaunchAuthority: { launchToken: 'transcript-launch', launchAgent: options.launchAgent } + }) + } // Why the guard: a restore seed is only applied to a never-written record, so the restore // cases must not write an empty chunk first. if (options.data.length > 0) { diff --git a/src/main/runtime/antigravity-terminal-readiness.test.ts b/src/main/runtime/antigravity-terminal-readiness.test.ts new file mode 100644 index 00000000000..198bf504926 --- /dev/null +++ b/src/main/runtime/antigravity-terminal-readiness.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptPreview +} from './terminal-wait-detection' + +const HEADER = 'Antigravity CLI 1.2.0' + +describe('Antigravity terminal readiness', () => { + it('accepts the idle composer without requiring model or account rows', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\nlogo glyphs custom provider\n>`)).toBe(true) + }) + + it('accepts an idle screen after an agent response with a numbered list', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\n1. First result\n2. Second result\n>`)).toBe(true) + }) + + it('refuses a model picker drawn after an older composer', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\n>\nGemini 3.7 Flash (current)`)).toBe(false) + }) + + it.each([ + 'Signing in...', + 'Loading workspace...', + 'Initializing MCP servers...', + '> Gemini 3.7 Flash (current)', + 'unexpected startup state' + ])('fails closed while the last visible row is %j', (row) => { + expect(isKnownReadyPromptPreview(`${HEADER}\n${row}`)).toBe(false) + }) + + it('treats a last-row spinner as busy even when an older composer remains in the tail', () => { + expect(isKnownReadyPromptPreview(`${HEADER}\n>\nGenerating...`)).toBe(false) + }) + + /** + * Provenance: transcribed from a live agy 1.2.7 / Gemini 3.8 Flash session observed through + * Orca on 2026-09-21, NOT a byte-exact PTY capture — node-pty could not be rebuilt on this + * Windows host (winpty's GetCommitHash.bat fails under node-gyp), so the recorder in + * docs/reference/agent-pty-transcript-capture.md was unavailable. The account row and the + * workspace path are scrubbed per that doc's privacy table. Replace this with a real + * transcript fixture once a host that can run the recorder is available. + * + * What it pins: agy 1.2.7 launches in accept-edits mode by default and paints that mode into + * the composer row, so a bare-caret-only rule never established readiness and every supervised + * worker timed out at agent_readiness. + */ + it('accepts the composer when agy paints its edit mode into the caret row', () => { + const acceptEdits = [ + 'Antigravity CLI 1.2.7', + 'redacted@example.com (Google AI Ultra)', + 'Gemini 3.8 Flash (High)', + '~/workspace/example', + '> Accept-edits mode: file edits auto-approved (shift+tab to cycle)' + ].join('\n') + expect(isKnownReadyPromptPreview(acceptEdits)).toBe(true) + }) + + it('still refuses a menu dialog whose highlighted row merely starts with a caret', () => { + // Guards the widened composer rule: every dialog prefixes its selection with '> '. + expect(isKnownReadyPromptPreview(`${HEADER}\n> Yes, I trust this folder`)).toBe(false) + expect(isKnownReadyPromptPreview(`${HEADER}\n> Gemini 3.8 Flash`)).toBe(false) + expect(isKnownReadyPromptPreview(`${HEADER}\n> /model Set a model`)).toBe(false) + }) + + it('refreshes a stale trust block after the composer appears without answering it', () => { + const trust = `${HEADER}\nDo you trust this workspace folder?\n> Yes, I trust this folder` + expect(detectTerminalWaitBlockedReason(trust)).toBe('agent-trust-workspace') + + const acceptedByUser = `${trust}\n${HEADER}\n>` + expect(detectTerminalWaitBlockedReason(acceptedByUser)).toBeNull() + expect(isKnownReadyPromptPreview(acceptedByUser)).toBe(true) + }) +}) diff --git a/src/main/runtime/antigravity-terminal-readiness.ts b/src/main/runtime/antigravity-terminal-readiness.ts new file mode 100644 index 00000000000..3e624434f89 --- /dev/null +++ b/src/main/runtime/antigravity-terminal-readiness.ts @@ -0,0 +1,117 @@ +import { isTerminalWaitWhitespace } from './terminal-wait-tail-window' + +/** + * Antigravity paints its chrome with cursor addressing, so model/account rows are not stable + * line anchors. The idle composer is the only captured marker that survives every ready screen. + */ +export function findAntigravityReadyPromptIndex(normalized: string): number | null { + return findAntigravityComposerIndex(normalized, true) +} + +/** Visible-screen snapshots may omit the banner after a dialog closes. */ +export function isAntigravityReadyPromptSnapshot(text: string): boolean { + return findAntigravityComposerIndex(text.toLowerCase(), false) !== null +} + +/** + * The composer is a bare `>` on the captured 3.7 Flash screens, but agy 1.2.7 paints the active + * edit mode into that same line (`> Accept-edits mode: file edits auto-approved (shift+tab to + * cycle)`), so a bare-caret-only rule never establishes readiness on a default 3.8 Flash launch. + * + * Why this stays narrow: every menu dialog also prefixes its highlighted row with `> ` — + * `> Yes, I trust this folder` (trust), `> Gemini 3.8 Flash` (model picker). Matching any + * `> ` would make all of them read as ready, which is the bug the bare-caret rule was + * guarding against. Only a caret alone, or a caret followed by ` mode:`, counts. + */ +function isComposerLine(value: string): boolean { + return value === '>' || /^>\s+[a-z][a-z-]*\s+mode:\s/i.test(value) +} + +function isModelRow(line: string): boolean { + const trimmed = line.trim() + if ( + !trimmed || + trimmed === '>' || + trimmed.includes('antigravity cli') || + /^resume with -c|^agy --conversation=/i.test(trimmed) + ) { + return false + } + if ( + trimmed.includes('@') || + trimmed.includes('antigravity business') || + trimmed.includes('for shortcuts') || + trimmed.startsWith('~/') || + trimmed.startsWith('/') || + /^[a-z]:\\/i.test(trimmed) + ) { + return false + } + return true +} + +function findAntigravityComposerIndex(normalized: string, requireHeader: boolean): number | null { + const headerIndex = normalized.lastIndexOf('antigravity cli') + const contentStart = headerIndex === -1 ? 0 : headerIndex + if (requireHeader && headerIndex === -1) { + return null + } + + let offset = 0 + let composerStart: number | null = null + let workspaceBeforeComposer = false + let workspaceAfterComposer = false + let modelAfterComposer = false + while (offset <= normalized.length) { + const lineStart = offset + const newlineIndex = normalized.indexOf('\n', lineStart) + const lineEnd = newlineIndex === -1 ? normalized.length : newlineIndex + let trimmedStart = lineStart + let trimmedEnd = lineEnd + while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { + trimmedStart += 1 + } + while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { + trimmedEnd -= 1 + } + const lineValue = normalized.slice(trimmedStart, trimmedEnd) + if (trimmedStart >= contentStart && isComposerLine(lineValue)) { + composerStart = trimmedStart + modelAfterComposer = false + } else if (trimmedStart >= contentStart) { + const value = lineValue + const isWorkspace = + value.startsWith('~/') || value.startsWith('/') || /^[a-z]:\\/i.test(value) + if (composerStart === null) { + workspaceBeforeComposer ||= isWorkspace + } else { + workspaceAfterComposer ||= isWorkspace + modelAfterComposer ||= isModelRow(value) + } + } + offset = lineEnd + 1 + if (newlineIndex === -1) { + break + } + } + if (composerStart === null) { + return null + } + // A trailing caret also appears on trust, sign-in, model, and onboarding menus. Those panes + // must remain blocked until the menu is gone; only the latest AGY screen can establish readiness. + if ( + /do you trust|sign in|select a model|collect usage|choose a theme|press enter to continue/.test( + normalized.slice(contentStart) + ) + ) { + return null + } + if (!workspaceBeforeComposer) { + return modelAfterComposer ? null : composerStart + } + return modelAfterComposer && !workspaceAfterComposer ? null : composerStart +} + +export function hasAntigravityTerminalHeader(text: string): boolean { + return text.toLowerCase().includes('antigravity cli') +} diff --git a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts index 51e81ee3d5d..1093319783a 100644 --- a/src/main/runtime/orca-runtime-resolve-terminal-pane.ts +++ b/src/main/runtime/orca-runtime-resolve-terminal-pane.ts @@ -232,7 +232,9 @@ export class OrcaRuntimeWithResolveTerminalPane extends OrcaRuntimeWithGetTermin opts: { limit?: number } = {} ): Promise { const visibleState = await this.readVisibleTerminalState(ptyId) - const projection = visibleState ?? (await this.readProviderTerminalTailLines(ptyId, opts.limit)) + const projection = + visibleState ?? + (await this.readProviderTerminalTailLines(ptyId, opts.limit, { visibleScreenOnly: true })) if (projection.lines.length === 0) { return { ...read, source: 'screen-unavailable' } } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 6bc02c7656d..a93250f5d51 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -354,8 +354,8 @@ export class OrcaRuntimeWithRuntimeId { getPaneAgent: (ptyId) => this.getPaneAgentForTuiIdle(ptyId), getFirstPartyAgentStatus: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, - startVisibleReadProbe: (waiter, waiterTimeoutMs) => - this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs) + startVisibleReadProbe: (waiter, waiterTimeoutMs, agent) => + this.startTuiIdleVisibleReadProbe(waiter, waiterTimeoutMs, agent) }, this.terminalWaiters, this.terminalIdlePolls diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index aa630277078..bb03b17eca3 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -24,6 +24,8 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { createSetupCompletionScanner } from './orchestration/setup-completion-signal' +import { isAntigravityReadyPromptSnapshot } from './antigravity-terminal-readiness' +import type { TuiAgent } from '../../shared/tui-agent' export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWithCreateAgentPromptRenderGate { /** One bounded look at the provider's screen for an adopted PTY whose retained @@ -31,7 +33,11 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith * screen already showing a settled prompt", and the poll above owns every * later transition. A provider screen that is still working when this fires * resolves through the poll, not here. */ - protected startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void { + protected startTuiIdleVisibleReadProbe( + waiter: TerminalWaiter, + waiterTimeoutMs: number, + agent: TuiAgent | null + ): void { const settleMarginMs = Math.min( TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS, Math.max(1, Math.floor(waiterTimeoutMs / 3)) @@ -48,7 +54,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith return } void withTimeout( - this.readTerminal(waiter.handle, {}, { + this.readTerminal(waiter.handle, agent === 'antigravity' ? { screen: true } : {}, { timeoutMs: providerTimeoutMs, retireOnTimeout: true, // Why: the ready banner stays in scrollback for the whole session, so @@ -66,9 +72,16 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith ) { return } - const snapshotText = projection.tail.join('\n') + const snapshotText = + agent === 'antigravity' + ? [...projection.tail, projection.draft ?? ''].join('\n') + : projection.tail.join('\n') const blockedReason = detectTerminalWaitBlockedReason(snapshotText) - if (!blockedReason && !isKnownReadyPromptPreview(snapshotText)) { + const ready = + agent === 'antigravity' + ? isAntigravityReadyPromptSnapshot(snapshotText) + : isKnownReadyPromptPreview(snapshotText) + if (!blockedReason && !ready) { return } const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts new file mode 100644 index 00000000000..f93df6e4d7a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/antigravity-worker-lifecycle.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-types' +import { reconcileRequestedWorkerTerminalReleases } from '../../../../orchestration/worker-terminal-release-reconciliation' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +const READY_WAIT = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} satisfies RuntimeTerminalWait + +describe('Antigravity orchestration worker lifecycle', () => { + const h = createOrchestrationWorkerReleaseHarness() + + afterEach(() => h.cleanup()) + + it('owns the terminal immediately and delays prompt delivery until AGY is ready', async () => { + h.setup() + const readiness = h.deferred() + vi.spyOn(h.runtime, 'waitForTerminal').mockReturnValue(readiness.promise) + + const pending = h.startWorker({ agent: 'antigravity' }) + await vi.waitFor(() => expect(h.runtime.waitForTerminal).toHaveBeenCalled()) + + expect(h.runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::worktree', + expect.objectContaining({ startupAgent: 'antigravity', surfaceOwner: false }) + ) + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + expect(h.db.listWorkerTerminalResources({})[0]?.resource).toMatchObject({ + ownership_state: 'owned', + terminal_handle: 'term_worker' + }) + + readiness.resolve(READY_WAIT) + await expect(pending).resolves.toEqual( + expect.objectContaining({ dispatchId: expect.any(String) }) + ) + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledTimes(1) + }) + + it('stops only the owned AGY terminal', async () => { + h.setup() + const { dispatchId } = await h.startWorker({ agent: 'antigravity' }) + + await expect( + h.call('orchestration.workerStop', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'stopped', processAction: 'closed_agent_terminal' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledOnce() + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) + + it('releases an owned AGY terminal and recovers a transient stale endpoint', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.closeTerminal).mockRejectedValueOnce(new Error('Multiplexer disposed')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_pending', processAction: 'none' }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'releasing' + }) + + await expect(reconcileRequestedWorkerTerminalReleases(h.runtime)).resolves.toMatchObject({ + attempted: 1, + released: 1 + }) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + release_state: 'released' + }) + expect(h.runtime.closeTerminal).toHaveBeenCalledTimes(2) + expect(h.runtime.closeTerminal).toHaveBeenNthCalledWith(2, 'term_worker') + }) + + it('fails closed on a stale AGY handle and releases it on a fresh retry', async () => { + h.setup() + const { dispatchId } = await h.startSettledWorker('succeeded', { + agent: 'antigravity' + }) + vi.mocked(h.runtime.showTerminal).mockRejectedValueOnce(new Error('terminal_handle_stale')) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'release_unknown' }) + expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)).toMatchObject({ + ownership_state: 'owned', + release_state: 'unknown' + }) + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ state: 'released' }) + expect(h.runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + }) +}) 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 1cf02efa012..7f885cb1a59 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 @@ -27,6 +27,40 @@ describe('orchestration worker launch preferences', () => { }) }) + it('passes an account-scoped Antigravity model and supported effort through the shared catalog', () => { + expect( + resolveWorkerLaunchPreferences({ + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + }) + ).toEqual({ + preferences: { model: 'gemini-3.1-pro-high', effort: 'high' }, + receipt: { + requested: { + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + }, + effective: { + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'high' + } + } + }) + }) + + it('rejects unsupported Antigravity effort values', () => { + expect(() => + resolveWorkerLaunchPreferences({ + agent: 'antigravity', + model: 'gemini-3.1-pro-high', + effort: 'xhigh' + }) + ).toThrow('does not support effort xhigh') + }) + it('does not invent an effort when only a model is requested', () => { expect( resolveWorkerLaunchPreferences({ agent: 'codex', model: 'gpt-5.6-sol' }).preferences diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts index a13ea320670..0eef66d466a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test-support.ts @@ -3,6 +3,20 @@ import { ORCHESTRATION_METHODS } from '../../orchestration' import { eraseRpcMethods, type RpcContext } from '../../../core' import { OrchestrationDb } from '../../../../orchestration/db' import { OrcaRuntimeService } from '../../../../orca-runtime' +import type { TuiAgent } from '../../../../../../shared/tui-agent' + +type WorkerStartOptions = { terminal?: string; agent?: TuiAgent } + +function isWorkerStartResult(value: unknown): value is { state: 'ready'; dispatchId: string } { + return ( + typeof value === 'object' && + value !== null && + 'state' in value && + value.state === 'ready' && + 'dispatchId' in value && + typeof value.dispatchId === 'string' + ) +} export function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void @@ -16,11 +30,11 @@ export type OrchestrationWorkerReleaseHarness = { setup: () => void cleanup: () => void call: (name: string, params: Record) => Promise - startWorker: (options?: { terminal?: string }) => Promise<{ taskId: string; dispatchId: string }> + startWorker: (options?: WorkerStartOptions) => Promise<{ taskId: string; dispatchId: string }> settle: (taskId: string, dispatchId: string, outcome: 'succeeded' | 'failed') => void startSettledWorker: ( outcome?: 'succeeded' | 'failed', - options?: { terminal?: string } + options?: WorkerStartOptions ) => Promise<{ taskId: string; dispatchId: string }> deferred: typeof deferred coordinatorPaneKey: string @@ -143,17 +157,19 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe return method.handler(parsed, ctx) } - async function startWorker(options: { terminal?: string } = {}): Promise<{ + async function startWorker(options: WorkerStartOptions = {}): Promise<{ taskId: string dispatchId: string }> { const task = db.createTask({ spec: 'release fixture task', runId: activeRunId }) - const result = (await call('orchestration.workerStart', { + const result = await call('orchestration.workerStart', { task: task.id, from: 'term_coord', - ...(options.terminal ? { terminal: options.terminal } : { agent: 'codex' }) - })) as { dispatchId: string; state: string } - expect(result.state).toBe('ready') + ...(options.terminal ? { terminal: options.terminal } : { agent: options.agent ?? 'codex' }) + }) + if (!isWorkerStartResult(result)) { + throw new Error('Expected worker-start to return a ready dispatch') + } return { taskId: task.id, dispatchId: result.dispatchId } } @@ -169,7 +185,7 @@ export function createOrchestrationWorkerReleaseHarness(): OrchestrationWorkerRe async function startSettledWorker( outcome: 'succeeded' | 'failed' = 'succeeded', - options: { terminal?: string } = {} + options: WorkerStartOptions = {} ): Promise<{ taskId: string; dispatchId: string }> { const worker = await startWorker(options) settle(worker.taskId, worker.dispatchId, outcome) diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index cf095f85775..67acaf2a60b 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -2,6 +2,7 @@ import type { RuntimeTerminalWait as RuntimeTerminalWaitResult, RuntimeTerminalWaitCondition } from '../../shared/runtime-types' +import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview @@ -31,7 +32,11 @@ type RuntimeTerminalWaitDependencies = { quiescenceMs: number getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus - startVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void + startVisibleReadProbe( + waiter: TerminalWaiter, + waiterTimeoutMs: number, + agent: TuiAgent | null + ): void } export class RuntimeTerminalWait { @@ -140,8 +145,20 @@ export class RuntimeTerminalWait { this.waiters.resolve(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) } else { this.polls.startPty(waiter, live.pty) - if (live.pty.lastAgentStatus === null && livePtyWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) + const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) + if ( + // AGY can retain a stale working/blocked status after a trust dialog was + // dismissed. Its visible composer is authoritative, so probe whenever the + // pane is identified as AGY (or its banner is present), regardless of that + // stale status. + (paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(livePtyWaitText) || + live.pty.lastAgentStatus === null) && + (livePtyWaitText.length === 0 || + paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(livePtyWaitText)) + ) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } } @@ -232,8 +249,16 @@ export class RuntimeTerminalWait { // while the last OSC title is still "working"; keep polling the // preview/title until the waiter resolves or hits its timeout. this.polls.startLeaf(waiter, live.leaf) - if (live.leaf.lastAgentStatus === null && liveLeafWaitText.length === 0) { - this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs) + const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) + if ( + (paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(liveLeafWaitText) || + live.leaf.lastAgentStatus === null) && + (liveLeafWaitText.length === 0 || + paneAgent === 'antigravity' || + hasAntigravityTerminalHeader(liveLeafWaitText)) + ) { + this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } } diff --git a/src/main/runtime/runtime-worktree-agent-startup.test.ts b/src/main/runtime/runtime-worktree-agent-startup.test.ts index 1575499ee68..e901555fa07 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.test.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' const mocks = vi.hoisted(() => ({ + markAntigravityWorkspaceTrusted: vi.fn(), markCodexProjectTrusted: vi.fn(), markCopilotFolderTrusted: vi.fn(), markCursorWorkspaceTrusted: vi.fn(), @@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../agent-trust-presets', () => ({ + markAntigravityWorkspaceTrusted: mocks.markAntigravityWorkspaceTrusted, markCodexProjectTrusted: mocks.markCodexProjectTrusted, markCopilotFolderTrusted: mocks.markCopilotFolderTrusted, markCursorWorkspaceTrusted: mocks.markCursorWorkspaceTrusted @@ -159,4 +161,29 @@ describe('markLocalWorktreeTrusted', () => { await expect(markLocalWorktreeTrusted('codex', '/workspace/app')).resolves.toBeUndefined() }) + + /** + * Why this test exists: Orca has two trust dispatch chains — the renderer's + * preflightAgentTrust (via the agentTrust:markTrusted IPC) and this main-process + * one, which is the only path `orchestration worker-start` takes. Adding + * `preflightTrust: 'antigravity'` to TUI_AGENT_CONFIG clears the `!preset` guard + * here but matched none of the cursor/copilot/codex branches, so every supervised + * agy worker still failed at agent_readiness with 'agent-trust-workspace' while + * the renderer-side unit tests passed. Verified live: with the branch added, the + * worktree is appended to ~/.gemini/antigravity-cli/settings.json and the dispatch + * reaches worker_done. + */ + it('writes the agy workspace trust artifact on the orchestration path', async () => { + await markLocalWorktreeTrusted('antigravity', '/workspace/app') + + expect(mocks.markAntigravityWorkspaceTrusted).toHaveBeenCalledWith('/workspace/app') + }) + + it('contains a throwing agy trust write', async () => { + mocks.markAntigravityWorkspaceTrusted.mockImplementationOnce(() => { + throw new Error('write failed') + }) + + await expect(markLocalWorktreeTrusted('antigravity', '/workspace/app')).resolves.toBeUndefined() + }) }) diff --git a/src/main/runtime/runtime-worktree-agent-startup.ts b/src/main/runtime/runtime-worktree-agent-startup.ts index bfd1274ef26..f71a81521a7 100644 --- a/src/main/runtime/runtime-worktree-agent-startup.ts +++ b/src/main/runtime/runtime-worktree-agent-startup.ts @@ -11,6 +11,7 @@ import { isTuiAgentEnabled, pickTuiAgent } from '../../shared/tui-agent-selectio import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '../../shared/tui-agent-startup' import { + markAntigravityWorkspaceTrusted, markCodexProjectTrusted, markCopilotFolderTrusted, markCursorWorkspaceTrusted @@ -200,6 +201,8 @@ export async function markLocalWorktreeTrusted( } else if (preset === 'codex') { // Why: the Codex write queues behind any in-flight hook grant, so the agent must not launch until it lands. await markCodexProjectTrusted(workspacePath) + } else if (preset === 'antigravity') { + markAntigravityWorkspaceTrusted(workspacePath) } } catch { // Best-effort: the user can still accept the agent trust prompt manually. diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index ebfcc5eaccf..34745b40c61 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -5,11 +5,8 @@ import { type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -import { - isTerminalWaitWhitespace, - startOfLastLines, - startOfLastNonBlankLines -} from './terminal-wait-tail-window' +import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' +import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window' const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -50,15 +47,6 @@ export function isKnownReadyPromptPreview(preview: string): boolean { if (readyIndex === null) { return false } - const antigravityReadyIndex = findAntigravityReadyPromptIndex(normalized) - const modelPickerIndex = findActiveAntigravityModelPickerIndex(normalized) - if ( - antigravityReadyIndex !== null && - modelPickerIndex !== null && - modelPickerIndex > antigravityReadyIndex - ) { - return false - } const blockedSignal = findTerminalWaitBlockedSignal(normalized) if (blockedSignal !== null && blockedSignal.index > readyIndex) { return false @@ -136,59 +124,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null { return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null } -function findAntigravityReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('antigravity cli') - if (headerIndex === -1) { - return null - } - let lineStart = headerIndex - let promptIndex: number | null = null - let previousNonEmpty: { start: number; end: number } | null = null - - // Why: a column-0 caret is ready; an indented `>` under `> draft` is a wrap, not an empty box. - for (let cursor = headerIndex; cursor <= normalized.length; cursor += 1) { - if (cursor < normalized.length && normalized.charCodeAt(cursor) !== 10) { - continue - } - let trimmedStart = lineStart - let trimmedEnd = cursor - while (trimmedStart < trimmedEnd && isTerminalWaitWhitespace(normalized, trimmedStart)) { - trimmedStart += 1 - } - while (trimmedEnd > trimmedStart && isTerminalWaitWhitespace(normalized, trimmedEnd - 1)) { - trimmedEnd -= 1 - } - if (lineStart > headerIndex && trimmedStart < trimmedEnd) { - if ( - trimmedEnd - trimmedStart === 1 && - normalized.charCodeAt(trimmedStart) === 62 && - trimmedStart === lineStart && - !( - previousNonEmpty !== null && - normalized.charCodeAt(previousNonEmpty.start) === 62 && - previousNonEmpty.end - previousNonEmpty.start > 1 - ) - ) { - promptIndex = trimmedStart - } - previousNonEmpty = { start: trimmedStart, end: trimmedEnd } - } - lineStart = cursor + 1 - } - - return promptIndex -} - -// Why: the model picker keeps the ready composer's bare caret in scrollback while its selected row -// is labeled, so that stale caret must not satisfy tui-idle until the picker emits its exit marker. -function findActiveAntigravityModelPickerIndex(normalized: string): number | null { - const pickerIndex = normalized.lastIndexOf('switch model') - if (pickerIndex === -1 || normalized.lastIndexOf('antigravity cli') > pickerIndex) { - return null - } - return normalized.lastIndexOf('exited /model command') > pickerIndex ? null : pickerIndex -} - export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 0cfce06803b..7c538a990f1 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -50,7 +50,7 @@ export type AgentStatusApi = { export type AgentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' workspacePath: string connectionId?: string }) => Promise diff --git a/src/preload/api/agent-trust-bridge.ts b/src/preload/api/agent-trust-bridge.ts index 5aca3fd805c..a9afa9c363d 100644 --- a/src/preload/api/agent-trust-bridge.ts +++ b/src/preload/api/agent-trust-bridge.ts @@ -3,7 +3,7 @@ import type { PreloadApi } from '../api-types' export const agentTrustApi = { markTrusted: (args: { - preset: 'cursor' | 'copilot' | 'codex' + preset: 'cursor' | 'copilot' | 'codex' | 'antigravity' workspacePath: string connectionId?: string }): Promise => ipcRenderer.invoke('agentTrust:markTrusted', args) diff --git a/src/shared/agent-session-option-catalog-antigravity.ts b/src/shared/agent-session-option-catalog-antigravity.ts new file mode 100644 index 00000000000..e6427a1070b --- /dev/null +++ b/src/shared/agent-session-option-catalog-antigravity.ts @@ -0,0 +1,37 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +const ANTIGRAVITY_EFFORT: CatalogOption = { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ], + defaultValue: 'high' + }, + apply: { + launchArgs: (value) => ['--effort', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--effort']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--effort']), + midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } + } +} + +export const ANTIGRAVITY_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + // Model availability is account-scoped; worker-start accepts the slug reported by `agy models`. + models: [], + modelApply: { + launchArgs: (value) => ['--model', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--model']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--model']), + midSession: { kind: 'agent-picker', command: '/model' } + }, + unknownModelOptions: [ANTIGRAVITY_EFFORT] +} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index ab10bb73153..afdb0a8f99c 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -1,4 +1,5 @@ import type { AgentType } from './agent-status-types' +import { ANTIGRAVITY_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-antigravity' import { CLAUDE_SESSION_OPTION_CATALOG, CODEX_SESSION_OPTION_CATALOG, @@ -30,6 +31,7 @@ export type { export { createClaudeCatalogOptions } const CATALOGS: AgentSessionOptionCatalogMap = { + antigravity: ANTIGRAVITY_SESSION_OPTION_CATALOG, claude: CLAUDE_SESSION_OPTION_CATALOG, codex: CODEX_SESSION_OPTION_CATALOG, gemini: GEMINI_SESSION_OPTION_CATALOG, diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index e3403702aa8..256f7d30bec 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -37,7 +37,7 @@ export type TuiAgentConfig = { /** Startup env var that seeds the input without submitting, for agents with no `--prefill`-style flag (e.g. pi); avoids the paste-after-ready race. */ draftPromptEnvVar?: string /** Pre-write a trust artifact so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste (see agent-trust-presets.ts). */ - preflightTrust?: 'cursor' | 'copilot' | 'codex' + preflightTrust?: 'cursor' | 'copilot' | 'codex' | 'antigravity' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ @@ -178,6 +178,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { antigravity: { detectCmd: 'agy', promptInjectionMode: 'flag-prompt-interactive', + // Why: agy's first-launch trust menu consumes the bracketed paste, and its trust is + // exact-path rather than inherited, so every freshly created child worktree raises it + // again — a supervised worker would otherwise always fail at agent_readiness + // (agent-trust-presets.ts). + preflightTrust: 'antigravity', // Why: agy 1.2.x collapses long paste as "↑ N more lines" and expands it over seconds; byte // ingest alone (~500 ms on macOS) finishes before the composer is submit-ready. submitLineSettleMsPerLine: 45 diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index dd4f4e38f1b..48f756a84b8 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -54,6 +54,24 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'custom-codex-model', effort: 'high' }) }) + it('forwards Antigravity worker model and effort without dropping permission defaults', () => { + const plan = buildAgentStartupPlan({ + agent: 'antigravity', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true, + sessionOptions: { model: 'gemini-3.1-pro-high', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true, + agentArgs: '--dangerously-skip-permissions' + }) + expect(plan?.launchCommand).toBe( + "agy '--dangerously-skip-permissions' '--model' 'gemini-3.1-pro-high' '--effort' 'high'" + ) + expect(plan?.launchConfig.agentCommand).toBe("agy '--dangerously-skip-permissions'") + expect(plan?.sessionOptions).toEqual({ model: 'gemini-3.1-pro-high', effort: 'high' }) + }) + it('inserts worker preferences before an argument terminator', () => { const plan = buildAgentStartupPlan({ agent: 'codex',