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 @@ -21,6 +21,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
Dedicated Nordic å in `/model` is treated as that shortcut when Add
Provider is offered.

### Changed

- Cancelling a `task` or `wait_agents` worker reports wait status `interrupted`,
not `failed`.

### Fixed

- Codex ChatGPT subscription sessions no longer show a public-rate dollar
Expand Down
6 changes: 4 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
- Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"`
- Streams assistant text deltas to stdout; lifecycle errors to stderr
- Shares ChatDirector compaction continuation (`requestContinuation` → content-less deliver after compact) so long runs do not stall post-compact
- Single primary `agent.send(task)` turn; samples run-sink status/error **before** close (close emits `reactor.done` which would clear sticky errors); then closes the agent before draining the stream so the process exits; toolset is always disposed in `finally`
- Single primary `agent.send(task)` turn; samples run-sink status/error **before** close (close emits `reactor.done` which would clear sticky errors); then closes the agent before draining the stream so the process exits; `finally` cancels live sub-agents (`subAgentSessions.cancelAll("Session closed")`, matching TUI runtime-shutdown), closes the agent, and always disposes the toolset
- Status: chat sessions rarely emit `reactor.done` before close, so a completed `send()` maps to `done` unless the pre-close run sink holds a real error
- Used by `scripts/demo.ts` (mode `exec`) and the capability eval suite (`scripts/eval-capability.ts` / `evals/capability/`)

Expand Down Expand Up @@ -226,7 +226,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:

- **Mount-time gate — live today, and fails closed.** `task-tool.ts` 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 the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `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`, `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 own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
- **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`, `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`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` sets the mailbox interrupt overlay so wait unblocks while a queued followup may already be running. The wait path collects a terminal status so a later followup cannot resurrect an already-observed interrupt. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`.
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.

#### Closed director fleet (`src/agent/directors/`)
Expand Down Expand Up @@ -295,6 +295,8 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP

**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.

**Wait mailbox** (`src/subagent/agent-fleet.ts` `FleetMailbox`): per-install overlay over that session store. Wait JSON is a projection of stored lifecycle plus mailbox membership, pin, collected, and optional interrupt override — not a second terminal store. Mailbox `register` pins an uncollected result (honored by prune); past `MAX_FLEET_RECORDS` the oldest never-collected pin is compacted to a tombstone. Operator cancel projects wait status `interrupted`.

**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest` → `observeSessionFromSubAgents`, `src/tui/runner-host.ts`, picking the newest running child else the most recent session of any status) is triggered by Alt+O (`shell.ts:observeActiveSubagent`) — the command palette action that used to call it is gone along with `src/tui/palette.ts` itself, but the chord replaces it rather than dropping the feature.

Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`).
Expand Down
4 changes: 2 additions & 2 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
type SubAgentSessionStore,
} from "../subagent/index.js";
import {
createFleetRecords,
createFleetMailbox,
createSpawnAgentTool,
createWaitAgentsTool,
createListAgentsTool,
Expand Down Expand Up @@ -329,7 +329,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
const orchestratorTools: AgentTool[] = [];
if (subAgentsEnabled && args.subAgent !== undefined) {
const sa = args.subAgent;
const fleetRecords = sa.sessions !== undefined ? createFleetRecords() : undefined;
const fleetRecords = sa.sessions !== undefined ? createFleetMailbox(sa.sessions) : undefined;
orchestratorTools.push(
createTaskTool({
cwd,
Expand Down
55 changes: 37 additions & 18 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ import {
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
import {
createSubAgentSessionStore,
type SubAgentProvider,
type SubAgentSessionStore,
} from "../subagent/index.js";
import type {
ContextStore,
InferenceSource,
Expand Down Expand Up @@ -116,6 +120,33 @@ export function formatCaughtError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

/**
* Headless analogue of TUI `runtime-shutdown`: abort live workers, then close
* the primary agent and dispose the toolset. `cancelAll` is fire-and-forget —
* it does not serialize `closeOne`.
*/
export async function disposeExecRuntime(args: {
agent: { close: () => Promise<unknown> } | null;
toolset: { dispose: () => Promise<unknown> } | null;
subAgentSessions: Pick<SubAgentSessionStore, "cancelAll"> | null;
}): Promise<void> {
args.subAgentSessions?.cancelAll("Session closed");
if (args.agent !== null) {
await args.agent.close().catch((err: unknown) => {
logger.debug("agent.close during exec finally failed: {error}", {
error: formatCaughtError(err),
});
});
}
if (args.toolset !== null) {
await args.toolset.dispose().catch((err: unknown) => {
logger.debug("toolset.dispose during exec finally failed: {error}", {
error: formatCaughtError(err),
});
});
}
}

/**
* Exec-primary director overlay. Omit / skywalker keep the product default
* (`loadSessionChatPrompt` + advertised session tools). Any other closed-fleet
Expand Down Expand Up @@ -248,6 +279,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
let connectedMcp: ConnectedMcpServer[] = [];
let agent: Agent | null = null;
let toolset: AgentToolset | null = null;
let subAgentSessions: SubAgentSessionStore | null = null;
let textOut = "";
let finalized = false;
let turnsUsed = 0;
Expand Down Expand Up @@ -392,7 +424,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
const liveSubAgentProvider: { current: SubAgentProvider } = {
current: buildSubAgentProvider(config),
};
const subAgentSessions = createSubAgentSessionStore();
const fleetSessions = createSubAgentSessionStore();
subAgentSessions = fleetSessions;
const shellTimeout = shellTimeoutFromSettings(config.settings);
const toolWatchdog = toolWatchdogFromSettings(config.settings);
const toolAvailability: ToolAvailability = {
Expand Down Expand Up @@ -447,7 +480,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
? {
subAgent: {
provider: () => liveSubAgentProvider.current,
sessions: subAgentSessions,
sessions: fleetSessions,
getWorkdirBase: () => sessionDir(config.cwd, sessionId),
onProgress: () => undefined,
...(config.settings !== undefined ? { settings: () => config.settings! } : {}),
Expand Down Expand Up @@ -888,21 +921,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
model: config.model,
};
} finally {
if (agent !== null) {
await agent.close().catch((err: unknown) => {
logger.debug("agent.close during exec finally failed: {error}", {
error: formatCaughtError(err),
});
});
}
// Match TUI: always dispose toolset (MCP clients + posix/plugin resources).
if (toolset !== null) {
await toolset.dispose().catch((err: unknown) => {
logger.debug("toolset.dispose during exec finally failed: {error}", {
error: formatCaughtError(err),
});
});
}
await disposeExecRuntime({ agent, toolset, subAgentSessions });
}
}

Expand Down
Loading
Loading