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
8 changes: 7 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ The **`spawn_agent`** tool starts a fleet agent on a separate inference source (

Workers ask the spawning parent with **`ask_director`** (not the human). That parks a question while the worker stays `running`. On the TUI primary that arrives as an idle-send wake. On a nested orchestrator the question arrives as mailbox mail with an `awaiting_director` status — that is not terminal. Once a parked ask is surfaced (TUI wake, nested mailbox mail, or a successful `list_agents`), further `list_agents` calls fail closed until **`send_input`** answers or the ask is dropped — `list_agents` is not a poll. The parent answers with **`send_input`**, then continues. Escalate to the human with **`ask_operator`** only when the parent cannot resolve it.

The ask is never prose-only: `ask_director` requires a typed **`escalation`** object (`src/subagent/escalation-policy.ts`, `ask-director.ts`) with `policyVersion` plus the required facts — `classification`, `blockedOutcome`, `unavailableDirectorPath`, `permittedAlternatives`, `minimumAddition`, and `declineConsequence` (each a non-empty string, byte-bounded). It may also carry `recommendation`, `safeDefault`, `requestedMechanism` versus `minimumAuthority`, and an optional `verification` detail (`verificationOutcome`, `rootCause`, `attemptedNarrowChecks`, `reducedConfidence`). The five `classification` values decide where the ask settles (`decideEscalation`): `routine` never parks — the worker continues with one of its displayed permitted alternatives and the evaluation is recorded for audit (`routineEvaluations`, newest-only cap); it requires at least one alternative with `comparableConfidence: true`. `director_resolvable` also continues internally when such a comparable alternative exists and parks otherwise. `operator_decision_required`, `outcome_blocked`, and `irreversible_or_sensitive` always park — the first and last require an explicit `minimumAuthority`, which must differ from `requestedMechanism` when both are present — and every parked classification requires at least one permitted alternative to show what was already considered.

The parent resolves a parked ask with **`send_input`**'s optional structured `resolution` (`questionId`, `kind`, `answer`; `parseEscalationResolution` validates it at the tool boundary). The four kinds are `director_answer` (normal answer), `declined`, `unavailable`, and `minimum_grant_available` (resume by text only — it grants nothing and leaves a denied-call envelope unconsumed). A resolution is bound to the pending `questionId` — a stale or mismatched id is refused so a late answer cannot settle a newer question — and is mutually exclusive with `interrupt`. The resolution's message is delivered to the worker together with the structured answer; neither can silently drop the other. A resolution never grants authority or retries a tool — a denied call still needs the envelope-owned covering-grant retry path described above.

Projections keep the policy facts visible: wait JSON and `list_agents` carry the pending ask's `question`, `questionId`, and `assessment`, and the session's `terminal_outcome` (`verification_blocked` when a `declined`/`unavailable` resolution lands on an ask that carried a `verification` detail, `declined` for a plain decline/unavailability, or `verified` when the worker reports a successful verification in place of a blocked one) is projected through wait JSON, `list_agents`, and mailbox mail (`src/subagent/agent-fleet.ts`, `fleet-report.ts`).

When the parent TUI is not blocked in an in-flight tool call, the runner publishes an authoritative snapshot of currently pending top-level questions on each store notification, including empty snapshots before fleet-count updates. During synchronous session rotation, a runner-owned barrier suppresses both publications before delivery-generation invalidation, transcript clearing, and worker cancellation; successful reset reconciles a fresh snapshot before resuming asynchronous backend rebuild. The bridge drops resolved, cancelled, replaced, terminal, and removed asks and delivers each session/question identity once while pending. A coalesced wake starts only when the parent is not processing and every operator gate is closed, including parent-idle fleet holds where the shell stays busy. Worker gates do not manufacture parent processing. Replies use `send_input`'s `target` field with the worker session ID, never its shared catalog ID. Synthetic wakes use `SessionPort.deliver` through delivery-queue's idle-send path without entering the user follow-up queue or composer `/feedback` capture.

When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`** plugins, including **data-only** markdown plugins with no `index.ts`), the chat model also receives **`search_agents`** — a lexical index over profile id, description, and role text so the model can discover ids before calling `spawn_agent(agent=...)`. Default results are id, description, and spawn metadata (orchestrator flag, source). Pass `include_body=true` to include each match's loaded system prompt / body (truncated) so the parent can inspect plugin or Claude marketplace agents without `read_file` on paths outside the session cwd (path-escape blocks those roots by design; writes remain blocked). `spawn_agent` and `search_agents` are core tools on the primary session.
Expand All @@ -273,7 +279,7 @@ Enforcement is runtime code at the existing tool-mount point, not prompt wording

- **Mount-time gate — live today, and fails closed.** `spawn_agent` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so `spawn_agent` rejects a profile-sourced orchestrator before starting a session. `FLEET_VERBS` in `authority.ts` names the live verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `wait_agents` explicit targets, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted` and, with `close_agent`, writes the mailbox interrupt overlay; `send_input` with `interrupt:true` does not. `send_input` with `interrupt:true` leaves wait live (`running`/`queued`) until the followup settles. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`.
- **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged.
- **Leaf `ask_director` (CL-6945).** Tier 3 leaves mount `ask_director` (not `ask_operator`). The worker evaluates caps, then awaits a session-store port; a missing port returns an error and does not suspend. `wait_agents` projects `awaiting_director` with `question` / `question_id` / `description` — this is wait JSON only, not a `WorkerLifecycle` state. Re-wait while still pending re-delivers the same question. Soft `send_input` answers the pending ask (it does not deliver a steer inbound). Interrupt / settle / close cancel the ask, including descendants. A worker blocked in `ask_director` is not stall-salvaged — stall pings are dropped for the park duration — but a park is not unbounded: parked asks expire after `ASK_DEADLINE_MS` (30 minutes) via `expireStaleAsks` (`src/subagent/session-store.ts`), which settles the ask with a timeout error naming question and session and expires any denied-call envelope the ask carried (lapsed grants fail closed rather than lingering for a later retry).
- `spawn_agent` + `wait_agents` is the only spawn path. The tier check still gates which packages may mount any fleet verb.

#### Closed director fleet (`src/agent/directors/`)
Expand Down
27 changes: 27 additions & 0 deletions docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,33 @@ by a history rewrite (compaction) are removed from the tree on the next commit.
per-commit git tree still grows one entry per spilled tool-output blob across the
session; that tree re-write is inherent to git and left as residual cost.

The wrapper also owns the on-disk transcript through a claim marker so two
writer processes cannot silently fork one history. The first appendable write
creates `session-owner.json` with an exclusive-create (`wx`) write; success
means the writer owns the existing log. A writer that finds a marker owned by
another process either reads the log back (append, when the live turns are a
prefix of what it intends to write) or repairs it in place (rewrite, when the
incoming turns are a compaction of the live log), and otherwise diverts.
Diversion abandons the shared base names and re-binds turns and prompts to
unique versioned bases, `turns-<epoch>-<uuid>.jsonl` and the same shape for
prompt (`versionedBaseName`), so the other owner's history is never touched
and versioned segments cannot be mistaken for live numbered segments by a
base reader. Epoch+uuid avoids numbered segments (`turns-0001.jsonl`) that
base readers treat as live history.

A rewrite is also a repair of a shared log, so it is staged in memory until
the next `commit()` lands it unchanged: the wrapper publishes the staged
rewrite to the working-tree segments, stages the resulting paths, and only
then commits; a failed commit restores the published generation and leaves
the rewrite staged for a retry. What counts as a legitimate rewrite of the
shared log is a pure predicate, so an unrelated content change cannot pose as
compaction: `isCompactionOf` requires the incoming turns to strip compaction
markers, carry at least one summary turn, and project their remaining tail
from the live log — each projected turn keeps role and tool-call ids while
`projectsFrom` allows only content-preserving projections of the shared log
(excerpt shortening, thinking-only omission stubs, projected tool results, or
a coalesced plain-text span). Anything else diverts.

### Crash Logging

`index.ts` installs `uncaughtException` and `unhandledRejection` handlers (and catches a rejected `main`). Each calls `writeCrashReport` (`crash/report.ts`) to write a best-effort report to `~/.corbits/projects/<project-key>/errors/<timestamp>.txt`, using the same `projectKeyFor`/`projectSessionsRoot` (`session/project-key.ts`) that keys that project's session directories, so a crash report lands next to the session's `run.json` and transcript rather than under a separately computed slug. The file records the failure kind, an ISO timestamp, the cwd, and the stack. `projectSessionsRoot` shells out to git with no timeout, so the handler never calls it directly — `primeCrashReporting` resolves and caches the directory once at startup (right after config load), and `writeCrashReport` only ever reads that cached value; if priming never ran or failed, it falls back to an `unresolved` bucket rather than touching git mid-crash. `writeCrashReport` swallows its own errors and returns `null` without logging; the handler in `index.ts` is what prints the one-line failure notice to stderr when that happens, then exits non-zero.
Expand Down
30 changes: 22 additions & 8 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,20 @@ REQUIRED or QUEUED INPUT strips are also visible, they stack between it and
the prompt box, in that order:

```
◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use? (+1 more)
◆ WORKER WAITING · director reply needed (send_input) · builder (copy assets): Which destination path should I use?; classification: director_resolvable; outcome: the export name is ambiguous in the current tree; minimum: read docs/ARCHITECTURE.md and pick; consequence: the rename lands under the wrong contract; recommendation: keep the existing export name; target: wkr-9f22 (+1 more)
◆ INPUT REQUIRED · operator answer needed · Which environment should I deploy to?
◆ QUEUED INPUT · 1
```

It names the worker, previews its question, and says the director owns the
reply. It is display only: composer text still goes to the primary director,
It names the worker, previews its assessed decision, and says the director
owns the reply. When the ask carries an escalation assessment, the preview is
the question joined with that decision summary (`workerWaitDecisionSummary`
in `src/tui/worker-wait.ts`): the escalation `classification`, the blocked
`outcome`, the `minimum` authority (or the minimum addition when no authority
is required), the decline `consequence`, a `recommendation` or `safeDefault`
line when the worker supplied one, and the `target` session — not the bare
question; an ask without an assessment previews the bare question. It is
display only: composer text still goes to the primary director,
and only the director's `send_input` to the worker's session answers the
worker. The strip reads the `agent-ask` pending snapshot and nothing else
(`src/tui/worker-wait.ts`, painted by `paintChrome` in `shell/chrome.ts`). An
Expand Down Expand Up @@ -479,9 +486,7 @@ because an earlier version could abandon the awaited promise on Escape and
leave the session parked with no recovery path short of killing the process;
Escape must always settle the promise it is dismissing.

The permissions prompt's title line names `/yolo` as the way to skip further
prompts, longest first as the terminal narrows: `Esc cancel · Enter choose ·
/yolo skip prompts`, then `Esc · Enter · /yolo`, then `Esc · Enter`. Operator
The permissions prompt's title line is a four-tier ladder of key hints, longest first as the terminal narrows: `Esc cancel · Enter choose · Ctrl+C stop · /yolo skip prompts`, then `Esc · Enter · Ctrl+C stop · /yolo`, then `Esc · Enter · Ctrl+C stop`, and finally the stop key alone — `Ctrl+C stop` — which is retained down to contentWidth 28 (interior 26). Below that even the last tier does not fit and the title falls back to ` Permissions` (`PERMISSIONS_HINTS` in `src/tui/overlay-view.ts`). Operator
questions and the model/provider picker do not advertise `/yolo`.

Once a permission or operator prompt is answered — or cancelled, timed out,
Expand All @@ -501,8 +506,17 @@ subject in the action color — the only Breakthrough Orange on the card. The
overlay host border and title use calm dim chrome (`UI.textDim`); consequence
impact in the description zone paints `UI.warning` (sand), not orange. A
blank row separates the subject from context. Choices are deliberately small:
each one is a bare, single-line action name (`Reject`, `Accept once`, the
scope's label) with no consequence text folded into the row. A scope's hint
each one is a bare, single-line action name (`Reject`, `Reject and stop`,
`Accept once`, the scope's label) with no consequence text folded into the row.
Every permission overlay carries the standing `Reject and stop` row between
`Reject` and `Accept once` (`permissionChoicesFromRequest` in
`src/tui/gate-wire.ts`). Selecting it settles the open gate as a plain deny —
no grant is minted, even when a persistable scope is offered — and then stops
the turn like Ctrl+C (`stopDecisionGate` in `shell/overlay-host.ts`): the
in-flight turn aborts and every queued permission gate is dropped, each
resolving as a deny that also mints no grant. Plain `Reject` declines only the
open gate and leaves the turn running; `Accept once` grants without
persisting, and a scope row persists that grant. A scope's hint
paints instead as a body message above the choice list
(`permissionBodyFromRequest` in `src/tui/gate-wire.ts`), and the expand key,
which binds only when the subject carries collapsed payloads, reveals the
Expand Down
Loading