Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

### Changed

- `wait_agents` is now exec-primary opt-in (`mountWaitAgents` in
`createAgentToolset`; CL-7678). The TUI primary and nested orchestrators no
longer mount it and collect fleet results through mailbox mail instead:
spawn then idle, do not poll. Headless `corbits exec` keeps it mounted with
an advertised allow.
- Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not
TTYs instead of advertising a cancel stub. TUI Skywalker still mounts it;
TTY exec still prompts on stdin.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,8 @@ Details live in `docs/PRODUCT.md` (safety model) and `docs/ARCHITECTURE.md`
Corbits Code is a single-process CLI built on Interchange primitives. The primary
session is always the **orchestrator** (Skywalker): it can act directly and
delegates substantial work through a closed director fleet via `spawn_agent`
then idle (mailbox mail inbound), `search_agents`, and optional `wait_agents`
for nested orchestrators.
then idle (mailbox mail inbound) and `search_agents`. `wait_agents` stays
mounted only on headless `corbits exec` primary runs.

```
CLI (src/index.ts)
Expand Down
22 changes: 11 additions & 11 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Unmatched shell auto-allows, including contained non-force `git worktree add`/`r

- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.

Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. TUI-primary `wait_agents` gets `shouldYieldWait` (queued steer or uncollected mail/ask) and finishes as a timeout without taking workers.
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count; the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. Skip that shot when a fleet-dry open-task continuation is latched. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).

`src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks.

Expand Down
6 changes: 3 additions & 3 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ The evidence is in how the product fails today: the personas already produce exc
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin.
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators still collect with `wait_agents`. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.
8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop).
9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored.

## User Experience

Expand Down Expand Up @@ -170,7 +170,7 @@ Corbits Code fans work out to short-lived **fleet agents** — workers with thei

- **Agents** are runtime entities (primary session or child).
- **Tasks** are checklist items owned by one agent via `manage_tasks`.
- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — do not poll `wait_agents`. Nested orchestrators still collect with `wait_agents`. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. Nested `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). When the parent TUI is not blocked in `wait_agents`, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`.
- **Fleet agents** are spawned with `spawn_agent`. On the TUI primary, mailbox mail arrives as inbound when a worker finishes or fails — spawn then idle; do not poll. Nested orchestrators collect through mailbox mail the same way. Workers ask the parent with `ask_director`. That parks a question while the worker stays `running`. On an exec-primary run, `wait_agents` returns `awaiting_director` with a question payload — that is not terminal. Once a parked ask is surfaced, `list_agents` fails closed until `send_input` answers or the ask is dropped — do not poll `list_agents`. The parent answers with `send_input` (`target` = the worker's session id). On the TUI primary, a parked question arrives as a synthetic idle-send wake. Escalate to the human only with `ask_operator`.

Dispatch uses a structured brief (context / goal / optional goals seed) and returns a structured report. The TUI Agents strip and fleet board show who is running; live tool progress updates the status bar without dumping the child transcript into the parent chat. There is no turn budget. A tool-less final turn completes only with the four-heading report envelope. Printed `<tool_call>` markup in assistant text gets one corrective nudge to issue a real tool call and does not count as the wrap-up; without the envelope, one incomplete-report nudge is given and a second tool-less turn without the envelope salvages as `incomplete-report-stop`. Counsel and `intent=plan` also require plan substance in Findings (files/paths, acceptance criteria, non-goals, risks, ordered steps); headings-only or stub Findings salvage as `incomplete-report`, not an attachable plan. A silent worker (no activity for `stallTimeoutMs`, opt-in) gets one continuation nudge, then salvages as `stalled` only after a full `stallTimeoutMs` grace with still no activity — queued checks inside that window wait, they do not salvage. An opt-in `deadlineMs`, or an operator cancel, can also end a run early. Each of these returns a salvage report so a runaway or idle child cannot quietly burn a large token budget or look done after prose alone.

Expand Down
6 changes: 3 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -628,9 +628,9 @@ Two mid-run gestures, two delivery times (CL-6290):
- **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the
next **parent** `tool.boundary` (the parent tool finishing, not a child) via
`Agent.deliver` into the live reactor, not a new `send`. A
long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds
steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a
steer is queued so occupancy can deliver it. The transcript row says
long parent `run_shell` is parent-busy and holds steers. A queued steer
delivers at the next parent `tool.boundary` so occupancy can pick it up.
The transcript row says
`[will steer next]` while pending and
`[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in
`runtime-bridge.ts`). If the captured target agent is already closed when
Expand Down
2 changes: 2 additions & 0 deletions scripts/eval-capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,8 @@ export async function buildEvalDiagnostics(
// Capability evals run through exec; they are non-TTY, so ask_operator
// is unmounted the same way the runner does when interactive is false.
operatorAvailable: false,
// ...and wait_agents stays mounted the way the exec runner mounts it.
waitAgentsMounted: true,
});
return {
advertisedTools,
Expand Down
2 changes: 1 addition & 1 deletion src/agent/agent-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function formatAgentSearchResults(
"",
...entries.flatMap((entry, i) => (i === 0 ? [entry] : ["", entry])),
"",
"Spawn with spawn_agent(description, prompt, agent=<id>). For a team, call spawn_agent once per member (parallel in one turn when independent), then reply and idle — mailbox mail arrives as inbound. Nested orchestrators still collect with wait_agents.",
"Spawn with spawn_agent(description, prompt, agent=<id>). For a team, call spawn_agent once per member (parallel in one turn when independent), then reply and idle — mailbox mail arrives as inbound. wait_agents is mounted on exec-primary runs only.",
].join("\n"),
);
}
Expand Down
4 changes: 3 additions & 1 deletion src/agent/directors/greybeard/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ describe("greybeardPackage", () => {
const allow = greybeardPackage.tools?.allow ?? [];
expect(allow).not.toContain("task");
expect(allow).toContain("spawn_agent");
expect(allow).toContain("wait_agents");
// CL-7678: nested orchestrators collect through mailbox mail; wait_agents
// is exec-primary opt-in, so it stays off the Greybeard allow.
expect(allow).not.toContain("wait_agents");
// CL-7051: search_agents is Skywalker-only — nested directors spawn from allowlist.
expect(allow).not.toContain("search_agents");
expect(allow).toContain("write_file");
Expand Down
2 changes: 1 addition & 1 deletion src/agent/directors/greybeard/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Judge the approach:
4. Rank risks for long-term maintainability and backward compatibility.
5. Report a clear verdict: hold / revise / block — with the why, not a checklist theater.

Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. Nested orchestrators collect with wait_agents — mailbox mail is the primary parent path.
Spawn only when a concrete unknown blocks that judgment. Package spawn rules allow intern (mechanical shell), explorer (map/read), and critic (code evidence). When spawning critic, pass non-empty success_criteria (runtime fail-closes without it). intern and explorer remain optional. Prefer doing the review yourself with mounted read/search tools. Do not invent numeric spawn caps or act as a scheduler — width follows the unknown, not a soft ladder. Spawn then idle; reports arrive as mailbox mail — do not poll.

Blinders: do not call search_agents to discover the fleet (even when nested). You already know the limited spawn set; stay inside it. Do not spawn builder, counsel, skywalker, or other directors outside the allowlist.

Expand Down
4 changes: 3 additions & 1 deletion src/agent/directors/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ describe("director registry", () => {
expect(s.systemPrompt).toMatch(/No catch-all worker/i);
expect(s.tools?.allow).not.toContain("task");
expect(s.tools?.allow).toContain("spawn_agent");
expect(s.tools?.allow).toContain("wait_agents");
// CL-7678: wait_agents is exec-primary opt-in, off the Skywalker allow —
// TUI primary collects through mailbox mail.
expect(s.tools?.allow).not.toContain("wait_agents");
expect(s.tools?.allow).toContain("write_file");
expect(s.tools?.allow).toContain("edit_file");
expect(s.tools?.allow).toContain("delete_file");
Expand Down
8 changes: 6 additions & 2 deletions src/agent/directors/skywalker/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ describe("skywalkerPackage", () => {
const allow = skywalkerPackage.tools?.allow ?? [];
expect(allow).not.toContain("task");
expect(allow).toContain("spawn_agent");
expect(allow).toContain("wait_agents");
// CL-7678: TUI primary collects through mailbox mail; wait_agents is
// exec-primary opt-in, so it stays off the Skywalker allow.
expect(allow).not.toContain("wait_agents");
expect(allow).toContain("search_agents");
expect(allow).toContain("write_file");
expect(allow).toContain("edit_file");
Expand Down Expand Up @@ -123,7 +125,9 @@ describe("skywalkerPackage", () => {
expect(p).toContain("wait_agents");
expect(p).toContain("Idle-orchestrator");
expect(p).not.toContain("task()");
expect(p).toContain("do not poll wait_agents");
expect(p).toContain("Spawn then idle; do not poll");
expect(p).not.toContain("do not poll wait_agents");
expect(p).toContain("wait_agents is mounted on exec-primary runs only");
expect(p).toContain("mailbox mail arrives as inbound");
expect(p).toContain(
"When the fleet goes dry the runtime re-enters with collected reports",
Expand Down
Loading
Loading