@aragon-agent/cli 0.6.40 → 0.6.43

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 (51) hide show
  1. package/CHANGELOG.md +2632 -2589
  2. package/README.md +3720 -3706
  3. package/dist/agent/controller.d.ts +36 -5
  4. package/dist/agent/controller.js +112 -33
  5. package/dist/agent/headless.js +6 -2
  6. package/dist/agent/system-prompt.d.ts +19 -0
  7. package/dist/agent/system-prompt.js +5 -0
  8. package/dist/commands/builtins.js +16 -182
  9. package/dist/commands/subagent-command.d.ts +21 -16
  10. package/dist/commands/subagent-command.js +22 -225
  11. package/dist/commands/team-command.d.ts +49 -0
  12. package/dist/commands/team-command.js +259 -0
  13. package/dist/config/model-kits.d.ts +2 -1
  14. package/dist/config/store.js +3 -2
  15. package/dist/exec/runner.js +2 -1
  16. package/dist/team/bus.d.ts +4 -0
  17. package/dist/team/bus.js +12 -2
  18. package/dist/team/comm-tools.js +3 -0
  19. package/dist/team/dispatch-gate.d.ts +86 -0
  20. package/dist/team/dispatch-gate.js +75 -0
  21. package/dist/team/forced-mode.d.ts +116 -0
  22. package/dist/team/forced-mode.js +156 -0
  23. package/dist/team/forced-prompt.d.ts +48 -0
  24. package/dist/team/forced-prompt.js +84 -0
  25. package/dist/team/limits.d.ts +41 -1
  26. package/dist/team/limits.js +49 -1
  27. package/dist/team/model.d.ts +22 -6
  28. package/dist/team/model.js +19 -5
  29. package/dist/team/overseer.d.ts +4 -0
  30. package/dist/team/overseer.js +38 -6
  31. package/dist/team/prompt.js +3 -3
  32. package/dist/team/report.d.ts +21 -0
  33. package/dist/team/report.js +40 -9
  34. package/dist/team/runtime.d.ts +3 -0
  35. package/dist/team/runtime.js +61 -5
  36. package/dist/team/subagent.js +25 -7
  37. package/dist/ui/App.js +140 -20
  38. package/dist/ui/StatusBar.d.ts +14 -0
  39. package/dist/ui/StatusBar.js +1 -0
  40. package/dist/ui/layout/status-layout.d.ts +17 -0
  41. package/dist/ui/layout/status-layout.js +9 -0
  42. package/dist/ui/overlays/HelpOverlay.js +8 -4
  43. package/dist/ui/overlays/SettingsScreen.d.ts +14 -0
  44. package/dist/ui/overlays/SettingsScreen.js +17 -7
  45. package/dist/ui/overlays/TeamFanoutPage.d.ts +17 -1
  46. package/dist/ui/overlays/TeamFanoutPage.js +23 -11
  47. package/dist/ui/overlays/kit-settings-state.js +3 -2
  48. package/dist/ui/overlays/model-profile-state.d.ts +1 -1
  49. package/dist/ui/overlays/team-fanout-state.d.ts +46 -13
  50. package/dist/ui/overlays/team-fanout-state.js +78 -20
  51. package/package.json +81 -81
@@ -265,4 +265,44 @@ export declare const TEAM_SUBAGENT_TOOL_NAMES: ReadonlySet<string>;
265
265
  * Bumped whenever the wording of either prompt block changes, so a behaviour
266
266
  * report can be tied to a block revision with one grep.
267
267
  */
268
- export declare const TEAM_BLOCK_VERSION = "v2-2026-10";
268
+ export declare const TEAM_BLOCK_VERSION = "v3-2026-10";
269
+ /**
270
+ * The version string on `<forced_team_mode>`'s first line.
271
+ *
272
+ * The block's words are a CONTRACT with the model, so a behaviour report ("it
273
+ * still did the work itself") has to be tieable to one revision. Kept beside
274
+ * `TEAM_BLOCK_VERSION` rather than reusing it: the two blocks answer different
275
+ * questions ("can dispatch" versus "must dispatch") and a shared number would
276
+ * name neither revision.
277
+ */
278
+ export declare const FORCED_TEAM_BLOCK_VERSION = "v1-2026-10";
279
+ /**
280
+ * Length ceiling of the `<forced_team_mode>` text.
281
+ *
282
+ * THE FIRST BLOCK LENGTH IN THIS SUBSYSTEM THAT A TEST ASSERTS: the `<team_mode>`
283
+ * block's 1200 is a comment in `team/prompt.ts`, and unifying the two is a
284
+ * separate small refactor (deliberately out of scope here). Looser than 1200
285
+ * because this block is only ever paid for while the session is ARMED, and it
286
+ * carries three layers of instruction rather than one.
287
+ */
288
+ export declare const FORCED_TEAM_BLOCK_MAX_CHARS = 1400;
289
+ /**
290
+ * Fallback nudges per user turn. ONE, and not configurable.
291
+ *
292
+ * The gate exists because the first two layers (the directive and the prompt
293
+ * block) both depend on the model cooperating. A second nudge would double the
294
+ * worst case of a model that is simply not going to dispatch - at N children per
295
+ * attempt - and D-8's whole argument is that the mechanical layer must be
296
+ * bounded by something other than the model's willingness.
297
+ */
298
+ export declare const FORCED_NUDGE_LIMIT = 1;
299
+ /**
300
+ * Grace window before the fallback nudge fires, in milliseconds.
301
+ *
302
+ * SAME ORDER AS THE TODO FOLLOW-THROUGH GRACE, for a structural reason rather
303
+ * than a stylistic one: both continuations share ONE `followTimer` in `App`
304
+ * (I-9), so "at most one armed continuation" is only explicable while the two
305
+ * numbers stay in the same order of magnitude. **Changing todo's grace means
306
+ * re-reading this constant.**
307
+ */
308
+ export declare const FORCED_NUDGE_GRACE_MS = 3000;
@@ -279,4 +279,52 @@ export const TEAM_SUBAGENT_TOOL_NAMES = new Set(['team_send', 'team_wait']);
279
279
  * Bumped whenever the wording of either prompt block changes, so a behaviour
280
280
  * report can be tied to a block revision with one grep.
281
281
  */
282
- export const TEAM_BLOCK_VERSION = 'v2-2026-10';
282
+ export const TEAM_BLOCK_VERSION = 'v3-2026-10';
283
+ // ---------------------------------------------------------------------------
284
+ // Forced team mode (`/team <brief>`, team-command-forced-dispatch §6.5).
285
+ //
286
+ // FOUR STRUCTURAL NUMBERS, here rather than in the modules that read them for
287
+ // the reason this file's header records: none of them is a user preference, and
288
+ // a `config.json` key for any of them would make "the model ignored the
289
+ // instruction" tunable by the person the instruction exists to protect.
290
+ // ---------------------------------------------------------------------------
291
+ /**
292
+ * The version string on `<forced_team_mode>`'s first line.
293
+ *
294
+ * The block's words are a CONTRACT with the model, so a behaviour report ("it
295
+ * still did the work itself") has to be tieable to one revision. Kept beside
296
+ * `TEAM_BLOCK_VERSION` rather than reusing it: the two blocks answer different
297
+ * questions ("can dispatch" versus "must dispatch") and a shared number would
298
+ * name neither revision.
299
+ */
300
+ export const FORCED_TEAM_BLOCK_VERSION = 'v1-2026-10';
301
+ /**
302
+ * Length ceiling of the `<forced_team_mode>` text.
303
+ *
304
+ * THE FIRST BLOCK LENGTH IN THIS SUBSYSTEM THAT A TEST ASSERTS: the `<team_mode>`
305
+ * block's 1200 is a comment in `team/prompt.ts`, and unifying the two is a
306
+ * separate small refactor (deliberately out of scope here). Looser than 1200
307
+ * because this block is only ever paid for while the session is ARMED, and it
308
+ * carries three layers of instruction rather than one.
309
+ */
310
+ export const FORCED_TEAM_BLOCK_MAX_CHARS = 1400;
311
+ /**
312
+ * Fallback nudges per user turn. ONE, and not configurable.
313
+ *
314
+ * The gate exists because the first two layers (the directive and the prompt
315
+ * block) both depend on the model cooperating. A second nudge would double the
316
+ * worst case of a model that is simply not going to dispatch - at N children per
317
+ * attempt - and D-8's whole argument is that the mechanical layer must be
318
+ * bounded by something other than the model's willingness.
319
+ */
320
+ export const FORCED_NUDGE_LIMIT = 1;
321
+ /**
322
+ * Grace window before the fallback nudge fires, in milliseconds.
323
+ *
324
+ * SAME ORDER AS THE TODO FOLLOW-THROUGH GRACE, for a structural reason rather
325
+ * than a stylistic one: both continuations share ONE `followTimer` in `App`
326
+ * (I-9), so "at most one armed continuation" is only explicable while the two
327
+ * numbers stay in the same order of magnitude. **Changing todo's grace means
328
+ * re-reading this constant.**
329
+ */
330
+ export const FORCED_NUDGE_GRACE_MS = 3000;
@@ -5,16 +5,16 @@
5
5
  * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
6
  *
7
7
  * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
- * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
- * `/team model` — following the `resolveFastTier` precedent one directory over:
10
- * four call sites each recomputing "which model do children run on?" from raw
8
+ * ELSE — `createSubagent`, the `task` report header and the settings root row —
9
+ * following the `resolveFastTier` precedent one directory over: three call
10
+ * sites each recomputing "which model do children run on?" from raw
11
11
  * config keys is exactly how the report ends up naming a model the children
12
12
  * silently fell back from.
13
13
  *
14
14
  * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
15
  * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
- * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
- * rewrite mid-session. A resolution captured at construction would be a
16
+ * library are all LIVE keys a settings save (the `Subagent model` row), `/fast
17
+ * off` or a kit can rewrite mid-session. A resolution captured at construction would be a
18
18
  * resolution describing a session that no longer exists.
19
19
  *
20
20
  * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
@@ -23,7 +23,7 @@
23
23
  * went stale. The reason is disclosed, never swallowed: the report header and
24
24
  * the settings summary line both render it.
25
25
  */
26
- import type { ModelRef, ThinkingLevel } from '@aragon-agent/core';
26
+ import type { ModelCost, ModelRef, ThinkingLevel, TokenUsage } from '@aragon-agent/core';
27
27
  import type { ModelProfile, ModelRole } from '../config/model-profiles.js';
28
28
  import type { CliConfig } from '../config/schema.js';
29
29
  /** Which model children run on. `main` is the default and the historical behavior. */
@@ -53,6 +53,22 @@ export interface SubagentModelResolution {
53
53
  /** Present only when `effective !== mode`. */
54
54
  fallbackReason?: SubagentFallbackReason;
55
55
  }
56
+ /** What `priceTeamUsage` needs from the controller: the lead's table and the lookup. */
57
+ export interface TeamPriceBook {
58
+ /** The lead's own price table (`main` mode prices at exactly this). */
59
+ mainCost: ModelCost | undefined;
60
+ isPriced: (ref: ModelRef) => boolean;
61
+ costFor: (ref: ModelRef) => ModelCost | undefined;
62
+ }
63
+ /**
64
+ * USD for one child `usage` event, at the model the children ACTUALLY ran.
65
+ *
66
+ * The status bar and the dispatch report must agree, so both price through the same
67
+ * resolution: under the default `main` mode this is the lead's table (byte parity),
68
+ * otherwise the fast / profile model's. A model no table prices contributes ZERO rather
69
+ * than a fabricated figure (C-11 / RV-4), where the report says `pricing unknown`.
70
+ */
71
+ export declare function priceTeamUsage(resolution: SubagentModelResolution, usage: TokenUsage, book: TeamPriceBook): number;
56
72
  /** The `AgentController.hasApiKey` shape, injected so there is one key resolver. */
57
73
  export type HasApiKey = (providerId: string, role?: ModelRole) => boolean;
58
74
  /** The lead-layer key resolver `resolveSubagentKey` falls back to. */
@@ -5,16 +5,16 @@
5
5
  * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
6
  *
7
7
  * EVERY CONSUMER READS THE CHILD'S MODEL THROUGH THIS FUNCTION AND NOWHERE
8
- * ELSE — `createSubagent`, the `task` report header, the settings root row and
9
- * `/team model` — following the `resolveFastTier` precedent one directory over:
10
- * four call sites each recomputing "which model do children run on?" from raw
8
+ * ELSE — `createSubagent`, the `task` report header and the settings root row —
9
+ * following the `resolveFastTier` precedent one directory over: three call
10
+ * sites each recomputing "which model do children run on?" from raw
11
11
  * config keys is exactly how the report ends up naming a model the children
12
12
  * silently fell back from.
13
13
  *
14
14
  * IT IS RE-RUN ON EVERY DISPATCH AND EVERY RENDER, NOT ONCE (rule 8 / RV-3 of
15
15
  * the fast tier, inherited here): `team.model`, the fast tier and the profile
16
- * library are all LIVE keys a settings save, `/fast off` or `/team model` can
17
- * rewrite mid-session. A resolution captured at construction would be a
16
+ * library are all LIVE keys a settings save (the `Subagent model` row), `/fast
17
+ * off` or a kit can rewrite mid-session. A resolution captured at construction would be a
18
18
  * resolution describing a session that no longer exists.
19
19
  *
20
20
  * A CHILD IS NEVER LEFT WITHOUT A MODEL (D-4). Every failure mode — fast tier
@@ -23,7 +23,21 @@
23
23
  * went stale. The reason is disclosed, never swallowed: the report header and
24
24
  * the settings summary line both render it.
25
25
  */
26
+ import { computeCost } from '../agent/usage.js';
26
27
  import { resolveFastTier } from '../fast/resolve.js';
28
+ /**
29
+ * USD for one child `usage` event, at the model the children ACTUALLY ran.
30
+ *
31
+ * The status bar and the dispatch report must agree, so both price through the same
32
+ * resolution: under the default `main` mode this is the lead's table (byte parity),
33
+ * otherwise the fast / profile model's. A model no table prices contributes ZERO rather
34
+ * than a fabricated figure (C-11 / RV-4), where the report says `pricing unknown`.
35
+ */
36
+ export function priceTeamUsage(resolution, usage, book) {
37
+ if (resolution.effective === 'main')
38
+ return computeCost(usage, book.mainCost);
39
+ return book.isPriced(resolution.ref) ? computeCost(usage, book.costFor(resolution.ref)) : 0;
40
+ }
27
41
  function mainRef(config) {
28
42
  return {
29
43
  providerId: config.provider,
@@ -293,5 +293,9 @@ export declare class TeamOverseer {
293
293
  * THE NOTE TELLS THE FRESH CHILD WHAT THE DEAD ONE DID (`filesTouched` is
294
294
  * best-effort, the same caveat the report's conflict warning carries): a
295
295
  * replacement that rewrites a half-written file blind is not a recovery.
296
+ *
297
+ * ONLY THE VARIABLE MIDDLE IS EVER CLAMPED. The closing sentence and the blank line
298
+ * that separates the note from the brief are what make the note a note; clamping the
299
+ * whole string from the tail glued an ellipsis onto the first word of the brief.
296
300
  */
297
301
  export declare function buildReplacementPrompt(spec: SubagentSpec, decision: OverseerDecision, previousRun: SubagentRun): string;
@@ -629,8 +629,33 @@ export class TeamOverseer {
629
629
  // ---------------------------------------------------------------------------
630
630
  // Replacement brief
631
631
  // ---------------------------------------------------------------------------
632
- /** Headroom the amended brief keeps over the original prompt's clamp. */
633
- const REPLACEMENT_NOTE_CHARS = 800;
632
+ /**
633
+ * Headroom the amended brief keeps over the original prompt's clamp. Sized for the
634
+ * LARGEST legal note: the head, a reason (`overseerReasonChars`), the file list
635
+ * (`REPLACEMENT_FILES_CHARS`), a guidance (`overseerGuidanceChars`) and the closing
636
+ * sentence. A cap below that sum silently chops a supervisor's own words.
637
+ */
638
+ const REPLACEMENT_NOTE_CHARS = 1_900;
639
+ /** The file list gets a small slice of its own: 30 long paths must not crowd out guidance. */
640
+ const REPLACEMENT_FILES_CHARS = 240;
641
+ /** The last thing a replacement reads before its brief; never clamped away. */
642
+ const REPLACEMENT_NOTE_TAIL = ' Verify rather than assume prior work is complete.\n\n';
643
+ /** `a, b, +3 more`: whole paths only, never a path cut in half. */
644
+ function listFiles(files, max) {
645
+ const kept = [];
646
+ let used = 0;
647
+ for (const file of files) {
648
+ const next = used + file.length + (kept.length > 0 ? 2 : 0);
649
+ if (next > max)
650
+ break;
651
+ kept.push(file);
652
+ used = next;
653
+ }
654
+ const hidden = files.length - kept.length;
655
+ if (kept.length === 0)
656
+ return clampChars(files[0] ?? '', max);
657
+ return hidden > 0 ? `${kept.join(', ')}, +${hidden} more` : kept.join(', ');
658
+ }
634
659
  /**
635
660
  * Prepend the supervisor's note to a spec's prompt, bounding the TOTAL to
636
661
  * `TEAM_LIMITS.promptChars` so the replacement fits exactly what a
@@ -639,17 +664,24 @@ const REPLACEMENT_NOTE_CHARS = 800;
639
664
  * THE NOTE TELLS THE FRESH CHILD WHAT THE DEAD ONE DID (`filesTouched` is
640
665
  * best-effort, the same caveat the report's conflict warning carries): a
641
666
  * replacement that rewrites a half-written file blind is not a recovery.
667
+ *
668
+ * ONLY THE VARIABLE MIDDLE IS EVER CLAMPED. The closing sentence and the blank line
669
+ * that separates the note from the brief are what make the note a note; clamping the
670
+ * whole string from the tail glued an ellipsis onto the first word of the brief.
642
671
  */
643
672
  export function buildReplacementPrompt(spec, decision, previousRun) {
673
+ const head = `[supervisor note] A previous attempt at this task ran ${previousRun.turns} turns ` +
674
+ 'and was replaced. Reason: ';
644
675
  const files = previousRun.filesTouched.length > 0
645
- ? ` It already wrote to: ${previousRun.filesTouched.join(', ')}.`
676
+ ? ` It already wrote to: ${listFiles(previousRun.filesTouched, REPLACEMENT_FILES_CHARS)}.`
646
677
  : '';
647
678
  const guidance = decision.action === 'replace' && decision.guidance !== undefined
648
679
  ? ` Guidance: ${decision.guidance}`
649
680
  : '';
650
- const note = clampChars(`[supervisor note] A previous attempt at this task ran ${previousRun.turns} turns ` +
651
- `and was replaced. Reason: ${decision.reason}.${files}${guidance} ` +
652
- `Verify rather than assume prior work is complete.\n\n`, REPLACEMENT_NOTE_CHARS);
681
+ const fixedChars = head.length + REPLACEMENT_NOTE_TAIL.length;
682
+ const middleBudget = Math.max(0, REPLACEMENT_NOTE_CHARS - fixedChars);
683
+ const middle = clampChars(`${decision.reason}.${files}${guidance}`, middleBudget);
684
+ const note = `${head}${middle}${REPLACEMENT_NOTE_TAIL}`;
653
685
  const originalBudget = Math.max(0, TEAM_LIMITS.promptChars - note.length);
654
686
  return `${note}${spec.prompt.slice(0, originalBudget)}`;
655
687
  }
@@ -48,9 +48,9 @@ export function buildTeamBlock(params) {
48
48
  '',
49
49
  'Each subagent starts with no memory of this conversation, so its prompt must',
50
50
  'carry everything it needs: the goal, the files or areas it owns, and what to',
51
- 'report back. Give each one a disjoint set of files to write. Every subagent',
52
- 'runs on your own model. Subagents cannot dispatch further subagents and',
53
- 'cannot ask the user anything.',
51
+ 'report back. Give each one a disjoint set of files to write. Each subagent runs',
52
+ 'on the model the user configured for subagents (usually your own). Subagents',
53
+ 'cannot dispatch further subagents and cannot ask the user anything.',
54
54
  '',
55
55
  'You get one combined report when they all finish. Read it before deciding what',
56
56
  'to do next, and tell the user what the team found in your own words.',
@@ -57,6 +57,27 @@ export interface ReportOptions {
57
57
  * one layer lower down.
58
58
  */
59
59
  export declare function truncateBytes(text: string, maxBytes: number): string;
60
+ /**
61
+ * Wall time of a child's run for the machine-readable feeds (`-p` stderr, `exec` JSON).
62
+ *
63
+ * A child that was aborted while still queued carries `endedAt` but NO `startedAt`
64
+ * (`startedAt` means "an attempt began", and `selectPanelRows` / `teamClockText` rely on
65
+ * it), so `endedAt - 0` would be an epoch-sized number. A running child (`endedAt`
66
+ * unset) keeps reading `0`, exactly as the feeds always have.
67
+ */
68
+ export declare function runDurationMs(run: SubagentRun): number;
69
+ /**
70
+ * The form a written path is stored in: relative to the child's cwd with `/`
71
+ * separators when it lives under it, otherwise the resolved absolute path. `src/a.ts`,
72
+ * `./src/a.ts` and `<cwd>/src/a.ts` are one file and have to read as one file.
73
+ */
74
+ export declare function normalizeLedgerPath(cwd: string, path: string): string;
75
+ /**
76
+ * The identity two spellings are compared by. Case is folded on Windows only: ext4 and
77
+ * APFS disagree about case, and a missed warning beats an invented one for a message
78
+ * that is phrased as a warning (see `findFileConflicts`).
79
+ */
80
+ export declare function ledgerPathKey(path: string): string;
60
81
  /**
61
82
  * Files written by more than one child.
62
83
  *
@@ -20,6 +20,7 @@
20
20
  * outcome of a fan-out; an all-or-nothing result would throw away four successes
21
21
  * because of one HTTP 429.
22
22
  */
23
+ import { isAbsolute, relative, resolve } from 'node:path';
23
24
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
24
25
  import { TEAM_AGGREGATE_LABEL, TEAM_LIMITS } from './limits.js';
25
26
  /** The pseudo-label aggregate notices use; reserved at normalization. */
@@ -57,6 +58,19 @@ function statusBadge(run) {
57
58
  return '[stopped: turn cap reached]';
58
59
  return '[ok]';
59
60
  }
61
+ /**
62
+ * Wall time of a child's run for the machine-readable feeds (`-p` stderr, `exec` JSON).
63
+ *
64
+ * A child that was aborted while still queued carries `endedAt` but NO `startedAt`
65
+ * (`startedAt` means "an attempt began", and `selectPanelRows` / `teamClockText` rely on
66
+ * it), so `endedAt - 0` would be an epoch-sized number. A running child (`endedAt`
67
+ * unset) keeps reading `0`, exactly as the feeds always have.
68
+ */
69
+ export function runDurationMs(run) {
70
+ if (run.startedAt === undefined)
71
+ return 0;
72
+ return Math.max(0, (run.endedAt ?? 0) - run.startedAt);
73
+ }
60
74
  function elapsed(run) {
61
75
  if (run.startedAt === undefined)
62
76
  return '0.0s';
@@ -65,6 +79,26 @@ function elapsed(run) {
65
79
  function isOk(run) {
66
80
  return run.phase === 'done' && !run.error;
67
81
  }
82
+ /**
83
+ * The form a written path is stored in: relative to the child's cwd with `/`
84
+ * separators when it lives under it, otherwise the resolved absolute path. `src/a.ts`,
85
+ * `./src/a.ts` and `<cwd>/src/a.ts` are one file and have to read as one file.
86
+ */
87
+ export function normalizeLedgerPath(cwd, path) {
88
+ const absolute = resolve(cwd, path);
89
+ const rel = relative(cwd, absolute);
90
+ const inside = rel.length > 0 && rel.split(/[/\\]/)[0] !== '..' && !isAbsolute(rel);
91
+ return (inside ? rel : absolute).replace(/\\/g, '/');
92
+ }
93
+ /**
94
+ * The identity two spellings are compared by. Case is folded on Windows only: ext4 and
95
+ * APFS disagree about case, and a missed warning beats an invented one for a message
96
+ * that is phrased as a warning (see `findFileConflicts`).
97
+ */
98
+ export function ledgerPathKey(path) {
99
+ const key = resolve(path).replace(/\\/g, '/');
100
+ return process.platform === 'win32' ? key.toLowerCase() : key;
101
+ }
68
102
  /**
69
103
  * Files written by more than one child.
70
104
  *
@@ -77,17 +111,14 @@ export function findFileConflicts(runs) {
77
111
  const owners = new Map();
78
112
  for (const run of runs) {
79
113
  for (const path of run.filesTouched) {
80
- const list = owners.get(path) ?? [];
81
- if (!list.includes(run.label))
82
- list.push(run.label);
83
- owners.set(path, list);
114
+ const key = ledgerPathKey(path);
115
+ const entry = owners.get(key) ?? { path, labels: [] };
116
+ if (!entry.labels.includes(run.label))
117
+ entry.labels.push(run.label);
118
+ owners.set(key, entry);
84
119
  }
85
120
  }
86
- const out = [];
87
- for (const [path, labels] of owners) {
88
- if (labels.length > 1)
89
- out.push({ path, labels });
90
- }
121
+ const out = [...owners.values()].filter((entry) => entry.labels.length > 1);
91
122
  return out.sort((a, b) => a.path.localeCompare(b.path, 'en'));
92
123
  }
93
124
  function buildHeader(outcome, options) {
@@ -157,6 +157,9 @@ export declare class TeamRuntime {
157
157
  /** The live roster, for `/team` status. `null` when nothing is dispatching. */
158
158
  snapshot(): TeamSnapshot | null;
159
159
  dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal): Promise<DispatchOutcome>;
160
+ /** Idempotent: puts every per-dispatch field back where `dispatch()` found it. */
161
+ private resetDispatchState;
162
+ private runDispatch;
160
163
  /** One slot of the pool: take the next index until the list is exhausted. */
161
164
  private worker;
162
165
  /**
@@ -361,6 +361,36 @@ export class TeamRuntime {
361
361
  // Dispatch
362
362
  // -------------------------------------------------------------------------
363
363
  async dispatch(specs, requested, signal) {
364
+ try {
365
+ return await this.runDispatch(specs, requested, signal);
366
+ }
367
+ catch (error) {
368
+ // A throw out of the dispatch body (a child that cannot be constructed) must not
369
+ // leave `busy` latched: every later `task` call, `/team` and busy-guarded command
370
+ // of the session would answer "a dispatch is already running". The success path
371
+ // tears down inline and never reaches this.
372
+ this.resetDispatchState();
373
+ throw error;
374
+ }
375
+ }
376
+ /** Idempotent: puts every per-dispatch field back where `dispatch()` found it. */
377
+ resetDispatchState() {
378
+ if (this.dispatchTimer)
379
+ clearTimeout(this.dispatchTimer);
380
+ this.dispatchTimer = null;
381
+ for (const handle of this.live)
382
+ handle.unsubscribe();
383
+ this.monitor?.dispose();
384
+ this.monitor = null;
385
+ this.supervision?.clearAll();
386
+ this.supervision = null;
387
+ this.overseer = null;
388
+ this.live = [];
389
+ this.bus = null;
390
+ this.snapshotState = null;
391
+ this.busy = false;
392
+ }
393
+ async runDispatch(specs, requested, signal) {
364
394
  this.counter += 1;
365
395
  const dispatchId = `d${this.counter}`;
366
396
  const config = this.deps.getConfig();
@@ -703,7 +733,9 @@ export class TeamRuntime {
703
733
  // where the replacement is actually built and driven. `attempt` resets so
704
734
  // the fresh body gets its own cold-start budget.
705
735
  if (verdict !== null && verdict.action === 'replace' && !this.aborted) {
706
- this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict);
736
+ if (this.replaceChild(dispatchId, handles, index, makeHandle, handle, verdict) === null) {
737
+ return;
738
+ }
707
739
  attempt = 0;
708
740
  continue;
709
741
  }
@@ -734,7 +766,8 @@ export class TeamRuntime {
734
766
  this.monitor?.clear(run.label);
735
767
  return;
736
768
  }
737
- this.replaceChild(dispatchId, handles, index, makeHandle, handle, null);
769
+ if (this.replaceChild(dispatchId, handles, index, makeHandle, handle, null) === null)
770
+ return;
738
771
  await this.backoff(TEAM_LIMITS.retryBackoffMs);
739
772
  if (this.aborted) {
740
773
  const fresh = handles[index];
@@ -778,12 +811,13 @@ export class TeamRuntime {
778
811
  trigger: 'postmortem',
779
812
  });
780
813
  if (decision.action === 'replace' && overseer.replacementsLeft(run.label)) {
781
- this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
814
+ const fresh = this.replaceChild(ctx.dispatchId, ctx.handles, index, ctx.makeHandle, handle, {
782
815
  action: 'replace',
783
816
  reason: decision.reason,
784
817
  ...(decision.guidance !== undefined ? { guidance: decision.guidance } : {}),
785
818
  });
786
- await this.runOne(ctx, index);
819
+ if (fresh !== null)
820
+ await this.runOne(ctx, index);
787
821
  }
788
822
  }
789
823
  /**
@@ -822,13 +856,35 @@ export class TeamRuntime {
822
856
  }, handle.run),
823
857
  }
824
858
  : handle.spec;
825
- const fresh = makeHandle(spec);
859
+ let fresh;
860
+ try {
861
+ fresh = makeHandle(spec);
862
+ }
863
+ catch (err) {
864
+ // The old body is already unsubscribed and aborted, so nothing will ever update
865
+ // its run again: left alone the panel shows it running forever. Settle IT.
866
+ const run = handle.run;
867
+ const reason = err instanceof Error ? err.message : String(err);
868
+ run.phase = 'failed';
869
+ run.error = `${run.error ? `${run.error}; ` : ''}could not build replacement: ${reason}`;
870
+ run.endedAt = run.endedAt ?? Date.now();
871
+ this.supervision?.clear(run.label);
872
+ this.monitor?.clear(run.label);
873
+ this.publish(dispatchId, run);
874
+ return null;
875
+ }
826
876
  // Provably {0,0} under the cold-start predicate above (no turn completed,
827
877
  // so no `turn_end` and no usage event). Carried anyway so that a future
828
878
  // relaxation of the predicate cannot silently under-report spend - the most
829
879
  // misleading failure this feature can produce.
830
880
  fresh.run.usage = carried;
831
881
  fresh.run.retries = retries;
882
+ // The files the discarded body already wrote are on disk whatever happens next,
883
+ // so the report and the conflict warning must keep seeing them (and a chained
884
+ // replacement must keep telling the next body about them). ONLY the ledger moves:
885
+ // `turns` / `toolCalls` are live controls (turn cap, cold-start retry predicate,
886
+ // supervisor digest) that describe the CURRENT body. Empty on a cold-start retry.
887
+ fresh.run.filesTouched = handle.run.filesTouched.slice(0, TEAM_LIMITS.filesTouchedMax);
832
888
  if (replacements > 0)
833
889
  fresh.run.replacements = replacements;
834
890
  if (interventions !== undefined)
@@ -15,6 +15,7 @@ import { formatStreamError } from '../agent/reducer.js';
15
15
  import { buildSystemPrompt } from '../agent/system-prompt.js';
16
16
  import { buildFastBlock } from '../fast/prompt.js';
17
17
  import { createBuiltinTools, gatedToolTimeouts, } from '../tools/index.js';
18
+ import { ledgerPathKey, normalizeLedgerPath } from './report.js';
18
19
  import { describeToolActivity, pickActivityArgs, sanitizeActivity } from './activity.js';
19
20
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
21
  import { appendTimeline } from './timeline.js';
@@ -386,17 +387,26 @@ export function createSubagent(spec, deps, hooks) {
386
387
  textTail.length > 0 ? sanitizeActivity(textTail, TEAM_LIMITS.activityChars) : undefined;
387
388
  hooks.onUpdate({ ...run, filesTouched: [...run.filesTouched] });
388
389
  };
389
- const noteFile = (toolName, args) => {
390
+ /**
391
+ * The path a write tool is ABOUT to touch, or `undefined` for every other tool.
392
+ * Staged on the start event and committed on the end event, because only the end
393
+ * event knows whether the write happened (`isError`). The string is the path alone,
394
+ * never the file body (P1-4 / P1-5).
395
+ */
396
+ const stagedWritePath = (toolName, args) => {
390
397
  if (toolName !== 'write_file' && toolName !== 'edit_file')
391
- return;
398
+ return undefined;
392
399
  const path = args?.path;
393
- if (typeof path !== 'string' || path.length === 0)
394
- return;
395
- if (run.filesTouched.includes(path))
400
+ return typeof path === 'string' && path.length > 0 ? path : undefined;
401
+ };
402
+ const noteFile = (path) => {
403
+ const normalized = normalizeLedgerPath(deps.getCwd(), path);
404
+ const key = ledgerPathKey(normalized);
405
+ if (run.filesTouched.some((known) => ledgerPathKey(known) === key))
396
406
  return;
397
407
  if (run.filesTouched.length >= TEAM_LIMITS.filesTouchedMax)
398
408
  return;
399
- run.filesTouched.push(path);
409
+ run.filesTouched.push(normalized);
400
410
  };
401
411
  const unsubscribe = agent.subscribe((event) => {
402
412
  switch (event.type) {
@@ -476,18 +486,26 @@ export function createSubagent(spec, deps, hooks) {
476
486
  // Recorded for the DURATION only; the entry itself is appended on the end
477
487
  // event, when `ok` and `ms` are both known (§4.5.2).
478
488
  pendingTool = { name: event.toolName, at: Date.now() };
489
+ const stagedPath = stagedWritePath(event.toolName, event.args);
490
+ if (stagedPath !== undefined)
491
+ pendingTool.path = stagedPath;
479
492
  run.phase = event.toolName === 'team_wait' ? 'waiting' : 'tool';
480
493
  run.lastTool = event.toolName;
481
494
  run.toolCalls += 1;
482
495
  textTail = '';
483
496
  run.activity = undefined; // prose is stale now
484
497
  run.activityArgs = pickActivityArgs(event.args); // PICKED, not raw (P1-4)
485
- noteFile(event.toolName, event.args);
486
498
  break;
487
499
  case 'tool_execution_end': {
488
500
  const endedAt = Date.now();
489
501
  const startedAt = pendingTool?.at;
502
+ const writtenPath = pendingTool?.path;
490
503
  pendingTool = null;
504
+ // The ledger records a write that HAPPENED: a refused (plan gate, --confirm,
505
+ // sandbox) or failed write is not a touched file, and the timeline below
506
+ // already reads `isError` for the same reason.
507
+ if (writtenPath !== undefined && event.isError !== true)
508
+ noteFile(writtenPath);
491
509
  // THE ARGUMENTS ARE GONE BY NOW, on purpose (P1-5): `activityArgs` is cleared a
492
510
  // few lines below, and the timeline's text comes from `describeToolActivity` on
493
511
  // the PICKED subset, so a `write_file` body can never reach the board.