@aragon-agent/cli 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +128 -1
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +66 -2
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +28 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +13 -0
  22. package/dist/config/load.js +65 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +112 -16
  28. package/dist/config/schema.js +89 -6
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/logging/install.d.ts +85 -0
  33. package/dist/logging/install.js +94 -0
  34. package/dist/logging/logger.d.ts +1 -1
  35. package/dist/session/persist.d.ts +16 -0
  36. package/dist/session/persist.js +19 -1
  37. package/dist/team/activity.d.ts +67 -0
  38. package/dist/team/activity.js +130 -0
  39. package/dist/team/bus.d.ts +190 -0
  40. package/dist/team/bus.js +366 -0
  41. package/dist/team/comm-tools.d.ts +61 -0
  42. package/dist/team/comm-tools.js +221 -0
  43. package/dist/team/human-queue.d.ts +55 -0
  44. package/dist/team/human-queue.js +72 -0
  45. package/dist/team/limits.d.ts +123 -0
  46. package/dist/team/limits.js +128 -0
  47. package/dist/team/normalize.d.ts +39 -0
  48. package/dist/team/normalize.js +106 -0
  49. package/dist/team/panel-rows.d.ts +48 -0
  50. package/dist/team/panel-rows.js +75 -0
  51. package/dist/team/prompt.d.ts +49 -0
  52. package/dist/team/prompt.js +87 -0
  53. package/dist/team/report.d.ts +58 -0
  54. package/dist/team/report.js +205 -0
  55. package/dist/team/retry.d.ts +48 -0
  56. package/dist/team/retry.js +56 -0
  57. package/dist/team/runtime.d.ts +122 -0
  58. package/dist/team/runtime.js +406 -0
  59. package/dist/team/subagent.d.ts +90 -0
  60. package/dist/team/subagent.js +294 -0
  61. package/dist/team/task-tool.d.ts +47 -0
  62. package/dist/team/task-tool.js +118 -0
  63. package/dist/team/types.d.ts +172 -0
  64. package/dist/team/types.js +13 -0
  65. package/dist/tools/index.d.ts +48 -2
  66. package/dist/tools/index.js +26 -1
  67. package/dist/ui/App.js +97 -13
  68. package/dist/ui/StatusBar.d.ts +21 -0
  69. package/dist/ui/StatusBar.js +5 -2
  70. package/dist/ui/TeamPanel.d.ts +57 -0
  71. package/dist/ui/TeamPanel.js +134 -0
  72. package/dist/ui/Transcript.js +10 -0
  73. package/dist/ui/entries/TeamCard.d.ts +31 -0
  74. package/dist/ui/entries/TeamCard.js +39 -0
  75. package/dist/ui/glyphs.d.ts +10 -0
  76. package/dist/ui/glyphs.js +6 -0
  77. package/dist/ui/layout/AppShell.d.ts +11 -1
  78. package/dist/ui/layout/AppShell.js +3 -3
  79. package/dist/ui/overlays/HelpOverlay.js +12 -0
  80. package/dist/ui/transcript-text.js +20 -0
  81. package/dist/ui/use-startup-notices.d.ts +1 -1
  82. package/dist/ui/use-startup-notices.js +2 -1
  83. package/package.json +2 -2
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The cold-start retry decision (team-live-activity F-4).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
7
+ * toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
8
+ * written to the transcript, and no tool ran, so nothing was written to disk.
9
+ * Everything else about the child is fresh by construction.
10
+ *
11
+ * THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
12
+ * `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
13
+ * `retryable: true`, and every abort in this system reaches the provider as an
14
+ * aborted fetch - so a user pressing Esc produces a "retryable" error. The
15
+ * predicate below does NOT see that, and must not be asked to: `TeamRuntime`
16
+ * excludes the abort paths before it ever calls the predicate, and there are
17
+ * exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
18
+ * `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
19
+ * per-child timeout via the local `timedOut` flag). The turn-cap abort is
20
+ * excluded twice over, by `run.truncated` here and by `turns === 0`.
21
+ *
22
+ * There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
23
+ * agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
24
+ * breaks BEFORE emitting `message_update`, so on an aborted request the
25
+ * provider's `error` event never reaches a subscriber and `run.retryable` is
26
+ * never even set. That is a property of a `break` in another package that no
27
+ * test in this repo pins; it could change under a refactor of core's steering
28
+ * handling without anyone here noticing. The four exclusions stay, and the
29
+ * acceptance test keeps asserting all four from this side of the boundary.
30
+ */
31
+ import type { SubagentRun } from './types.js';
32
+ /**
33
+ * Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
34
+ *
35
+ * The error is constructed inside `packages/core` and crosses a package
36
+ * boundary; a duplicated core instance (a linked checkout, a hoisting accident)
37
+ * would make `instanceof` false while the field is plainly there, and the
38
+ * failure mode would be "retries silently stopped happening". `formatStreamError`
39
+ * reads `errorType` exactly this way for the same reason (reducer.ts).
40
+ */
41
+ export declare function isRetryableStreamError(err: unknown): boolean;
42
+ /**
43
+ * Whether this attempt may be replaced by a fresh child.
44
+ *
45
+ * `attemptsSoFar` counts the attempt that just ended, so the first failure calls
46
+ * this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
47
+ */
48
+ export declare function shouldRetryColdStart(run: SubagentRun, attemptsSoFar: number): boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The cold-start retry decision (team-live-activity F-4).
3
+ *
4
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
5
+ *
6
+ * RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
7
+ * toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
8
+ * written to the transcript, and no tool ran, so nothing was written to disk.
9
+ * Everything else about the child is fresh by construction.
10
+ *
11
+ * THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
12
+ * `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
13
+ * `retryable: true`, and every abort in this system reaches the provider as an
14
+ * aborted fetch - so a user pressing Esc produces a "retryable" error. The
15
+ * predicate below does NOT see that, and must not be asked to: `TeamRuntime`
16
+ * excludes the abort paths before it ever calls the predicate, and there are
17
+ * exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
18
+ * `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
19
+ * per-child timeout via the local `timedOut` flag). The turn-cap abort is
20
+ * excluded twice over, by `run.truncated` here and by `turns === 0`.
21
+ *
22
+ * There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
23
+ * agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
24
+ * breaks BEFORE emitting `message_update`, so on an aborted request the
25
+ * provider's `error` event never reaches a subscriber and `run.retryable` is
26
+ * never even set. That is a property of a `break` in another package that no
27
+ * test in this repo pins; it could change under a refactor of core's steering
28
+ * handling without anyone here noticing. The four exclusions stay, and the
29
+ * acceptance test keeps asserting all four from this side of the boundary.
30
+ */
31
+ import { TEAM_LIMITS } from './limits.js';
32
+ /**
33
+ * Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
34
+ *
35
+ * The error is constructed inside `packages/core` and crosses a package
36
+ * boundary; a duplicated core instance (a linked checkout, a hoisting accident)
37
+ * would make `instanceof` false while the field is plainly there, and the
38
+ * failure mode would be "retries silently stopped happening". `formatStreamError`
39
+ * reads `errorType` exactly this way for the same reason (reducer.ts).
40
+ */
41
+ export function isRetryableStreamError(err) {
42
+ return err?.retryable === true;
43
+ }
44
+ /**
45
+ * Whether this attempt may be replaced by a fresh child.
46
+ *
47
+ * `attemptsSoFar` counts the attempt that just ended, so the first failure calls
48
+ * this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
49
+ */
50
+ export function shouldRetryColdStart(run, attemptsSoFar) {
51
+ return (attemptsSoFar <= TEAM_LIMITS.maxColdStartRetries &&
52
+ run.retryable === true &&
53
+ run.turns === 0 &&
54
+ run.toolCalls === 0 &&
55
+ !run.truncated);
56
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
3
+ * disposal (team-subagents §3.2 / §3.5 / §3.8).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * ALL CONCURRENCY LIVES INSIDE ONE TOOL EXECUTION (I-1 / D-1). The core agent
8
+ * loop runs tool calls strictly sequentially, and that is not an accident to be
9
+ * "fixed": plan mode's human-input bridge holds ONE outstanding request slot and
10
+ * is correct only because tool calls never overlap (I-P11). So `task` takes a
11
+ * BATCH and the fan-out is scheduled here, where it is ours to control. Anyone
12
+ * tempted to make `agent-loop.ts` concurrent to "fix" parallel delegation is
13
+ * about to silently mis-route a plan approval to a question wizard.
14
+ *
15
+ * The scheduler is a plain slot pool: an index cursor plus `maxConcurrent`
16
+ * worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
17
+ * queue class, no event-loop trickery — every path is observable from the event
18
+ * stream.
19
+ */
20
+ import type { ProviderRegistry, SkillRegistry, ToolPolicyDecision, ToolPolicyVerdict } from '@aragon-agent/core';
21
+ import type { AgentMode } from '../agent/agent-mode.js';
22
+ import type { CliConfig } from '../config/schema.js';
23
+ import type { TeamHumanQueue } from './human-queue.js';
24
+ import { type SubagentAgentFactory } from './subagent.js';
25
+ import type { DispatchOutcome, SubagentSpec, TeamEventListener, TeamSnapshot } from './types.js';
26
+ export interface TeamRuntimeDeps {
27
+ /** Read LIVE: a settings-screen model or key edit must reach the next dispatch. */
28
+ getConfig: () => CliConfig;
29
+ providerRegistry: ProviderRegistry;
30
+ getCwd: () => string;
31
+ getMode: () => AgentMode;
32
+ getApiKey: (providerId: string) => string | undefined;
33
+ skillRegistry?: SkillRegistry;
34
+ alwaysBlock?: () => string;
35
+ toolPolicy?: () => ToolPolicyDecision;
36
+ onChildToolPolicyEvent?: (e: {
37
+ label: string;
38
+ tool: string;
39
+ verdict: ToolPolicyVerdict;
40
+ sourceNames: string[];
41
+ }) => void;
42
+ humanQueue?: TeamHumanQueue;
43
+ /** Test seam (§8.1): a stub factory keeps the whole scheduler off the network. */
44
+ agentFactory?: SubagentAgentFactory;
45
+ }
46
+ export declare class TeamRuntime {
47
+ private readonly deps;
48
+ private readonly listeners;
49
+ private live;
50
+ private bus;
51
+ private snapshotState;
52
+ private busy;
53
+ private aborted;
54
+ private counter;
55
+ private dispatchTimer;
56
+ /**
57
+ * Every in-flight retry backoff, so an abort releases ALL of them (P1-7).
58
+ *
59
+ * A SET, NOT ONE FIELD, and the plural is load-bearing: the failure that
60
+ * triggers a cold start is usually provider-wide, so a 429 fails every
61
+ * in-flight child's first request in the same tick and up to `maxConcurrent`
62
+ * children enter backoff together. With a single stored resolver two of three
63
+ * are overwritten and only the last is released, so "an abort during the
64
+ * backoff window ends the dispatch promptly" would hold for one child and
65
+ * quietly not for the others.
66
+ */
67
+ private readonly pendingBackoffs;
68
+ constructor(deps: TeamRuntimeDeps);
69
+ subscribe(listener: TeamEventListener): () => void;
70
+ /**
71
+ * Unreachable while I-1 holds, and present anyway so "only one dispatch at a
72
+ * time" is a property of THIS module rather than a consequence of another
73
+ * package's loop shape (R-14).
74
+ */
75
+ isBusy(): boolean;
76
+ /** The live roster, for `/team` status. `null` when nothing is dispatching. */
77
+ snapshot(): TeamSnapshot | null;
78
+ dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal): Promise<DispatchOutcome>;
79
+ /** One slot of the pool: take the next index until the list is exhausted. */
80
+ private worker;
81
+ /**
82
+ * Run one child to a terminal phase, replacing it once if its very first
83
+ * request died on a transport error before it did anything at all (F-4).
84
+ */
85
+ private runOne;
86
+ /**
87
+ * Swap a cold-started child for a fresh one, carrying only what must survive.
88
+ *
89
+ * `this.live` IS `handles`, so the single assignment below also makes
90
+ * `abortAll()` abort the NEW child and makes the outcome carry the new run.
91
+ * Anyone tempted to "clean up" that aliasing into a defensive copy must update
92
+ * both places.
93
+ */
94
+ private replaceForRetry;
95
+ /**
96
+ * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
97
+ * then error, then no-summary, then done.
98
+ *
99
+ * Extracted verbatim so the retry loop has one exit and the order lives in one
100
+ * place.
101
+ */
102
+ private settlePhase;
103
+ /**
104
+ * Sleep, releasing early when the dispatch aborts.
105
+ *
106
+ * The timer is `unref`'d so a pending backoff cannot hold the process open on
107
+ * exit, and the resolver is registered in `pendingBackoffs` so `abortAll()`
108
+ * releases EVERY one of them rather than only the last (P1-7).
109
+ */
110
+ private backoff;
111
+ /**
112
+ * Stop every child and settle every blocking wait.
113
+ *
114
+ * Idempotent, and safe to call when nothing is running — `AgentController.abort()`
115
+ * calls it unconditionally.
116
+ */
117
+ abortAll(): void;
118
+ /** Process-exit path: abort everything and clear timers (R-15). */
119
+ dispose(): void;
120
+ private publish;
121
+ private emit;
122
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
3
+ * disposal (team-subagents §3.2 / §3.5 / §3.8).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * ALL CONCURRENCY LIVES INSIDE ONE TOOL EXECUTION (I-1 / D-1). The core agent
8
+ * loop runs tool calls strictly sequentially, and that is not an accident to be
9
+ * "fixed": plan mode's human-input bridge holds ONE outstanding request slot and
10
+ * is correct only because tool calls never overlap (I-P11). So `task` takes a
11
+ * BATCH and the fan-out is scheduled here, where it is ours to control. Anyone
12
+ * tempted to make `agent-loop.ts` concurrent to "fix" parallel delegation is
13
+ * about to silently mis-route a plan approval to a question wizard.
14
+ *
15
+ * The scheduler is a plain slot pool: an index cursor plus `maxConcurrent`
16
+ * worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
17
+ * queue class, no event-loop trickery — every path is observable from the event
18
+ * stream.
19
+ */
20
+ import { TeamBus } from './bus.js';
21
+ import { TEAM_LIMITS } from './limits.js';
22
+ import { shouldRetryColdStart } from './retry.js';
23
+ import { createSubagent, defaultSubagentFactory, } from './subagent.js';
24
+ export class TeamRuntime {
25
+ deps;
26
+ listeners = new Set();
27
+ live = [];
28
+ bus = null;
29
+ snapshotState = null;
30
+ busy = false;
31
+ aborted = false;
32
+ counter = 0;
33
+ dispatchTimer = null;
34
+ /**
35
+ * Every in-flight retry backoff, so an abort releases ALL of them (P1-7).
36
+ *
37
+ * A SET, NOT ONE FIELD, and the plural is load-bearing: the failure that
38
+ * triggers a cold start is usually provider-wide, so a 429 fails every
39
+ * in-flight child's first request in the same tick and up to `maxConcurrent`
40
+ * children enter backoff together. With a single stored resolver two of three
41
+ * are overwritten and only the last is released, so "an abort during the
42
+ * backoff window ends the dispatch promptly" would hold for one child and
43
+ * quietly not for the others.
44
+ */
45
+ pendingBackoffs = new Set();
46
+ constructor(deps) {
47
+ this.deps = deps;
48
+ }
49
+ subscribe(listener) {
50
+ this.listeners.add(listener);
51
+ return () => this.listeners.delete(listener);
52
+ }
53
+ /**
54
+ * Unreachable while I-1 holds, and present anyway so "only one dispatch at a
55
+ * time" is a property of THIS module rather than a consequence of another
56
+ * package's loop shape (R-14).
57
+ */
58
+ isBusy() {
59
+ return this.busy;
60
+ }
61
+ /** The live roster, for `/team` status. `null` when nothing is dispatching. */
62
+ snapshot() {
63
+ return this.snapshotState;
64
+ }
65
+ // -------------------------------------------------------------------------
66
+ // Dispatch
67
+ // -------------------------------------------------------------------------
68
+ async dispatch(specs, requested, signal) {
69
+ this.counter += 1;
70
+ const dispatchId = `d${this.counter}`;
71
+ const config = this.deps.getConfig();
72
+ const team = config.team;
73
+ const startedAt = Date.now();
74
+ this.busy = true;
75
+ this.aborted = false;
76
+ // DECLARED BEFORE THE BUS, ASSIGNED AFTER THE HANDLES. `canSend` closes over
77
+ // this binding so it reads the LIVE array: F-4 replaces `handles[index]`
78
+ // mid-dispatch, and a captured array or a captured handle would answer for
79
+ // the discarded child. The bus is built from `specs` and the handles are
80
+ // built from the bus, so the existing construction order forces the hoist.
81
+ // `canSend` is only ever called from inside a `team_wait` on a child that
82
+ // does not exist yet, so the TDZ window is not reachable.
83
+ let handles = [];
84
+ /**
85
+ * Whether `label` still has a loop that could call `team_send` (F-3 / P0-2).
86
+ *
87
+ * NEGATIVE, over TERMINAL PHASES ONLY. "The child's phase is running" is the
88
+ * natural way to write this and it is wrong on the SHIPPED DEFAULTS: at
89
+ * `maxConcurrent: 3, maxSubagents: 5`, children 4 and 5 sit in `queued` for
90
+ * the first part of every five-way dispatch, and a wait on one of them would
91
+ * be refused with "nobody can answer: a4 already finished". `queued` is not
92
+ * terminal - that child has not started yet and will.
93
+ *
94
+ * An unknown label permits, so this can never become a second, silent path
95
+ * to a refusal: `checkWaitable` has its own `unknown_sender` branch.
96
+ */
97
+ const canSend = (label) => {
98
+ const handle = handles.find((h) => h.run.label === label);
99
+ if (!handle)
100
+ return true;
101
+ const phase = handle.run.phase;
102
+ return phase !== 'done' && phase !== 'failed' && phase !== 'aborted';
103
+ };
104
+ const bus = new TeamBus(specs.map((s) => s.label), {
105
+ onMessage: (message) => this.emit({ type: 'message', dispatchId, message }),
106
+ canSend,
107
+ });
108
+ this.bus = bus;
109
+ const subDeps = {
110
+ bus,
111
+ config,
112
+ providerRegistry: this.deps.providerRegistry,
113
+ getCwd: this.deps.getCwd,
114
+ getMode: this.deps.getMode,
115
+ getApiKey: this.deps.getApiKey,
116
+ ...(this.deps.skillRegistry ? { skillRegistry: this.deps.skillRegistry } : {}),
117
+ ...(this.deps.alwaysBlock ? { alwaysBlock: this.deps.alwaysBlock() } : {}),
118
+ ...(this.deps.toolPolicy ? { toolPolicy: this.deps.toolPolicy } : {}),
119
+ ...(this.deps.onChildToolPolicyEvent
120
+ ? { onChildToolPolicyEvent: this.deps.onChildToolPolicyEvent }
121
+ : {}),
122
+ confirmTools: config.confirmTools,
123
+ ...(this.deps.humanQueue ? { humanQueue: this.deps.humanQueue } : {}),
124
+ agentFactory: this.deps.agentFactory ?? defaultSubagentFactory,
125
+ };
126
+ // Hoisted into a factory so a retry can build a fresh child with IDENTICAL
127
+ // wiring (F-4). Replacing the child rather than re-prompting it is the only
128
+ // shape where the retried child is genuinely equivalent to the first:
129
+ // `Agent.prompt()` appends to the same history, so a second prompt would
130
+ // show the child its brief twice.
131
+ const makeHandle = (spec) => createSubagent(spec, subDeps, {
132
+ onUpdate: (run) => this.publish(dispatchId, run),
133
+ onUsage: (label, usage) => this.emit({ type: 'usage', dispatchId, label, usage }),
134
+ });
135
+ // ASSIGNMENT, not a declaration: a `const` here would shadow the `let` above
136
+ // the bus, `canSend` would see the empty array forever, and every peer would
137
+ // look live - which fails open rather than closed, so nothing would break
138
+ // loudly.
139
+ handles = specs.map(makeHandle);
140
+ this.live = handles;
141
+ this.snapshotState = {
142
+ dispatchId,
143
+ active: true,
144
+ runs: handles.map((h) => ({ ...h.run })),
145
+ requested,
146
+ startedAt,
147
+ messageCount: 0,
148
+ };
149
+ this.emit({ type: 'dispatch_start', dispatchId, requested, specs });
150
+ // TWO ABORT SOURCES, ON PURPOSE (§3.8). `ctx.signal` is the correct path
151
+ // today; `AgentController.abort()` also calls `abortAll()` directly, which
152
+ // makes "no child survives its dispatch" a property of the controller rather
153
+ // than a consequence of signal plumbing three modules away.
154
+ const onAbort = () => this.abortAll();
155
+ signal?.addEventListener('abort', onAbort, { once: true });
156
+ // The dispatch ceiling has to be enforced HERE as well as through
157
+ // `toolTimeoutOverrides.task`: the executor's timeout is COOPERATIVE (I-2),
158
+ // so the override only bites because `task` listens to `ctx.signal` — and a
159
+ // second, independent timer means a signal that never arrives still ends the
160
+ // dispatch.
161
+ this.dispatchTimer = setTimeout(() => this.abortAll(), team.dispatchTimeoutMs);
162
+ const runCtx = {
163
+ dispatchId,
164
+ handles,
165
+ makeHandle,
166
+ subagentTimeoutMs: team.subagentTimeoutMs,
167
+ };
168
+ const cursor = { next: 0 };
169
+ const workers = Array.from({ length: Math.max(1, Math.min(team.maxConcurrent, handles.length)) }, () => this.worker(runCtx, cursor));
170
+ await Promise.allSettled(workers);
171
+ if (this.dispatchTimer)
172
+ clearTimeout(this.dispatchTimer);
173
+ this.dispatchTimer = null;
174
+ signal?.removeEventListener('abort', onAbort);
175
+ for (const handle of handles)
176
+ handle.unsubscribe();
177
+ const outcome = {
178
+ dispatchId,
179
+ runs: handles.map((h) => ({ ...h.run, filesTouched: [...h.run.filesTouched] })),
180
+ requested,
181
+ startedAt,
182
+ endedAt: Date.now(),
183
+ aborted: this.aborted,
184
+ leadMail: bus.leadMail(),
185
+ usage: handles.reduce((acc, h) => ({
186
+ inputTokens: acc.inputTokens + h.run.usage.inputTokens,
187
+ outputTokens: acc.outputTokens + h.run.usage.outputTokens,
188
+ }), { inputTokens: 0, outputTokens: 0 }),
189
+ };
190
+ this.live = [];
191
+ this.bus = null;
192
+ this.snapshotState = null;
193
+ this.busy = false;
194
+ this.emit({ type: 'dispatch_end', dispatchId, outcome });
195
+ return outcome;
196
+ }
197
+ /** One slot of the pool: take the next index until the list is exhausted. */
198
+ async worker(ctx, cursor) {
199
+ for (;;) {
200
+ const index = cursor.next;
201
+ cursor.next += 1;
202
+ const handle = ctx.handles[index];
203
+ if (!handle)
204
+ return;
205
+ if (this.aborted) {
206
+ // Never started. Marked here rather than left `queued`, so the report
207
+ // accounts for every spec the dispatch accepted.
208
+ handle.run.phase = 'aborted';
209
+ handle.run.endedAt = Date.now();
210
+ this.publish(ctx.dispatchId, handle.run);
211
+ continue;
212
+ }
213
+ await this.runOne(ctx, index);
214
+ }
215
+ }
216
+ /**
217
+ * Run one child to a terminal phase, replacing it once if its very first
218
+ * request died on a transport error before it did anything at all (F-4).
219
+ */
220
+ async runOne(ctx, index) {
221
+ const { dispatchId, handles, makeHandle, subagentTimeoutMs } = ctx;
222
+ for (let attempt = 0;; attempt += 1) {
223
+ const handle = handles[index];
224
+ const { run, agent } = handle;
225
+ run.phase = 'starting';
226
+ run.startedAt = Date.now();
227
+ this.publish(dispatchId, run);
228
+ let timedOut = false;
229
+ const timer = setTimeout(() => {
230
+ timedOut = true;
231
+ agent.abort();
232
+ }, subagentTimeoutMs);
233
+ try {
234
+ await agent.prompt(handle.spec.prompt);
235
+ }
236
+ catch (err) {
237
+ // `Agent.prompt()` resolves even on failure, so this only fires for a
238
+ // stub factory or a genuine throw. Recorded rather than swallowed: a
239
+ // child that failed for a reason nobody can see is the worst outcome the
240
+ // report can produce.
241
+ run.error = err instanceof Error ? err.message : String(err);
242
+ }
243
+ finally {
244
+ clearTimeout(timer);
245
+ }
246
+ // THE TWO ABORT EXCLUSIONS THAT MAKE THE PREDICATE SAFE. Every abort in
247
+ // this system reaches the provider as an aborted fetch, and `core` marks
248
+ // an `AbortError` retryable - so Esc, `ctx.signal`, the dispatch timeout
249
+ // (all three via `this.aborted`) and the per-child timeout (`timedOut`)
250
+ // are excluded HERE, before `shouldRetryColdStart` is ever consulted.
251
+ const retryable = !this.aborted && !timedOut && shouldRetryColdStart(run, attempt + 1);
252
+ if (!retryable) {
253
+ run.endedAt = Date.now();
254
+ this.settlePhase(run, timedOut, subagentTimeoutMs);
255
+ this.publish(dispatchId, run);
256
+ return;
257
+ }
258
+ const fresh = this.replaceForRetry(dispatchId, handles, index, makeHandle);
259
+ await this.backoff(TEAM_LIMITS.retryBackoffMs);
260
+ if (this.aborted) {
261
+ fresh.run.phase = 'aborted';
262
+ fresh.run.endedAt = Date.now();
263
+ this.publish(dispatchId, fresh.run);
264
+ return;
265
+ }
266
+ }
267
+ }
268
+ /**
269
+ * Swap a cold-started child for a fresh one, carrying only what must survive.
270
+ *
271
+ * `this.live` IS `handles`, so the single assignment below also makes
272
+ * `abortAll()` abort the NEW child and makes the outcome carry the new run.
273
+ * Anyone tempted to "clean up" that aliasing into a defensive copy must update
274
+ * both places.
275
+ */
276
+ replaceForRetry(dispatchId, handles, index, makeHandle) {
277
+ const handle = handles[index];
278
+ const carried = { ...handle.run.usage };
279
+ const retries = (handle.run.retries ?? 0) + 1;
280
+ handle.unsubscribe();
281
+ try {
282
+ handle.agent.abort();
283
+ }
284
+ catch {
285
+ // Already gone; this must not stop the retry.
286
+ }
287
+ const fresh = makeHandle(handle.spec);
288
+ // Provably {0,0} under the predicate above (no turn completed, so no
289
+ // `turn_end` and no usage event). Carried anyway so that a future relaxation
290
+ // of the predicate cannot silently under-report spend - the most misleading
291
+ // failure this feature can produce.
292
+ fresh.run.usage = carried;
293
+ fresh.run.retries = retries;
294
+ handles[index] = fresh;
295
+ this.publish(dispatchId, fresh.run);
296
+ return fresh;
297
+ }
298
+ /**
299
+ * The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
300
+ * then error, then no-summary, then done.
301
+ *
302
+ * Extracted verbatim so the retry loop has one exit and the order lives in one
303
+ * place.
304
+ */
305
+ settlePhase(run, timedOut, subagentTimeoutMs) {
306
+ if (this.aborted) {
307
+ run.phase = 'aborted';
308
+ }
309
+ else if (timedOut) {
310
+ run.phase = 'failed';
311
+ run.error = run.error ?? `subagent exceeded ${Math.round(subagentTimeoutMs / 1000)}s`;
312
+ }
313
+ else if (run.error) {
314
+ run.phase = 'failed';
315
+ }
316
+ else if (!run.summary) {
317
+ // A child whose watchdog fired, or whose stream died silently, resolves
318
+ // with nothing. `[failed: run produced no output]` is the honest reading;
319
+ // `[ok]` with an empty section is not.
320
+ run.phase = 'failed';
321
+ run.error = 'run produced no output';
322
+ }
323
+ else {
324
+ run.phase = 'done';
325
+ }
326
+ }
327
+ /**
328
+ * Sleep, releasing early when the dispatch aborts.
329
+ *
330
+ * The timer is `unref`'d so a pending backoff cannot hold the process open on
331
+ * exit, and the resolver is registered in `pendingBackoffs` so `abortAll()`
332
+ * releases EVERY one of them rather than only the last (P1-7).
333
+ */
334
+ backoff(ms) {
335
+ return new Promise((resolve) => {
336
+ const done = () => {
337
+ clearTimeout(timer);
338
+ this.pendingBackoffs.delete(done);
339
+ resolve();
340
+ };
341
+ const timer = setTimeout(done, ms);
342
+ timer.unref?.();
343
+ this.pendingBackoffs.add(done);
344
+ });
345
+ }
346
+ // -------------------------------------------------------------------------
347
+ // Abort / dispose
348
+ // -------------------------------------------------------------------------
349
+ /**
350
+ * Stop every child and settle every blocking wait.
351
+ *
352
+ * Idempotent, and safe to call when nothing is running — `AgentController.abort()`
353
+ * calls it unconditionally.
354
+ */
355
+ abortAll() {
356
+ if (!this.busy)
357
+ return;
358
+ this.aborted = true;
359
+ for (const handle of this.live) {
360
+ try {
361
+ handle.agent.abort();
362
+ }
363
+ catch {
364
+ // A child that is already gone must not stop the others being aborted.
365
+ }
366
+ }
367
+ this.bus?.cancelAllWaits();
368
+ // Every in-flight retry backoff, not just the last one registered: a
369
+ // provider-wide failure puts up to `maxConcurrent` children here at once.
370
+ for (const release of [...this.pendingBackoffs])
371
+ release();
372
+ }
373
+ /** Process-exit path: abort everything and clear timers (R-15). */
374
+ dispose() {
375
+ this.abortAll();
376
+ if (this.dispatchTimer)
377
+ clearTimeout(this.dispatchTimer);
378
+ this.dispatchTimer = null;
379
+ this.listeners.clear();
380
+ }
381
+ // -------------------------------------------------------------------------
382
+ // Events
383
+ // -------------------------------------------------------------------------
384
+ publish(dispatchId, run) {
385
+ const copy = { ...run, filesTouched: [...run.filesTouched] };
386
+ if (this.snapshotState && this.snapshotState.dispatchId === dispatchId) {
387
+ this.snapshotState = {
388
+ ...this.snapshotState,
389
+ runs: this.snapshotState.runs.map((r) => (r.label === copy.label ? copy : r)),
390
+ messageCount: this.bus?.messageCount() ?? this.snapshotState.messageCount,
391
+ ...(this.bus?.lastMessage() ? { lastMessage: this.bus.lastMessage() } : {}),
392
+ };
393
+ }
394
+ this.emit({ type: 'agent_update', dispatchId, run: copy });
395
+ }
396
+ emit(event) {
397
+ for (const listener of [...this.listeners]) {
398
+ try {
399
+ listener(event);
400
+ }
401
+ catch {
402
+ // One bad subscriber must not take the dispatch down with it.
403
+ }
404
+ }
405
+ }
406
+ }