@aragon-agent/cli 0.6.42 → 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.
@@ -900,18 +900,15 @@ export class AgentController {
900
900
  // executor's timeout is cooperative, so an override is inert for any
901
901
  // tool that does not listen (I-2).
902
902
  //
903
- // `dispatchTimeoutMs: 0` (the default) reaches the executor as an
904
- // override of `0` = NO TIMER AT ALL: the dispatch lasts as long as its
905
- // slowest child, and only an Esc ends it early (main-agent parity).
906
- // D-4, THE SECOND ARMING SITE: the runtime arms its own dispatch
907
- // timer under the same gate, because the executor timeout being
908
- // cooperative means this override must never be the ONLY ceiling.
909
- // With the supervisor gate open the ceiling disarms HERE too, so
910
- // team.overseer can never leave two kill paths alive behind one
911
- // config. Read once at construction, exactly as before.
912
- ...(this.teamRegistered
913
- ? { task: config.team.overseer ? 0 : config.team.dispatchTimeoutMs }
914
- : {}),
903
+ // ALWAYS `0` = NO EXECUTOR TIMER, and the override itself must stay:
904
+ // deleting it would drop `task` back onto the executor's default tool
905
+ // timeout and kill every long fan-out at that default. The dispatch
906
+ // ceiling lives in ONE place, `TeamRuntime.dispatch()`, which re-reads
907
+ // `team.overseer` / `team.dispatchTimeoutMs` on every dispatch. The
908
+ // executor's table is frozen at construction, so a ceiling armed here
909
+ // outlived a mid-session `Settings > Team` switch to the supervisor
910
+ // and killed the dispatch the supervisor was meant to look after.
911
+ ...(this.teamRegistered ? { task: 0 } : {}),
915
912
  },
916
913
  },
917
914
  });
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { formatCost, formatTokens, formatDuration, computeCost } from './usage.js';
8
8
  import { formatStreamError } from './reducer.js';
9
+ import { runDurationMs } from '../team/report.js';
9
10
  import { derivePlanPhase } from '../todo/acceptance.js';
10
11
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
11
12
  /** Run a single prompt headlessly and resolve with the process exit code. */
@@ -99,19 +100,22 @@ export async function runHeadless(controller, prompt, options = {}) {
99
100
  if (quiet)
100
101
  return;
101
102
  if (event.type === 'dispatch_start') {
103
+ // Labels default to a1, a2... on every dispatch, so "once only" is per dispatch.
104
+ started.clear();
105
+ finished.clear();
102
106
  err.write(`\n[team] dispatch ${event.specs.length} subagents\n`);
103
107
  return;
104
108
  }
105
109
  if (event.type === 'agent_update') {
106
110
  const { run } = event;
107
- if (run.phase !== 'queued' && !started.has(run.label)) {
111
+ if (run.startedAt !== undefined && !started.has(run.label)) {
108
112
  started.add(run.label);
109
113
  err.write(`[team] ${run.label} start "${run.description}"\n`);
110
114
  }
111
115
  const terminal = run.phase === 'done' || run.phase === 'failed' || run.phase === 'aborted';
112
116
  if (terminal && !finished.has(run.label)) {
113
117
  finished.add(run.label);
114
- const ms = Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0));
118
+ const ms = runDurationMs(run);
115
119
  const verdict = run.phase === 'done' && !run.error ? 'ok' : run.phase;
116
120
  err.write(`[team] ${run.label} ${verdict} ${formatDuration(ms)} ${run.toolCalls} tools\n`);
117
121
  }
@@ -15,6 +15,7 @@
15
15
  * same `--session-id` must not race the writer of the file it is about to read.
16
16
  */
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
+ import { runDurationMs } from '../team/report.js';
18
19
  import { setSignalTerminator } from '../logging/install.js';
19
20
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
21
  import { EXEC_SCHEMA_VERSION, buildRequestEndEvent } from './events.js';
@@ -1622,7 +1623,7 @@ export class ExecRunner {
1622
1623
  label: run.label,
1623
1624
  phase: run.phase,
1624
1625
  description: run.description,
1625
- durationMs: Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0)),
1626
+ durationMs: runDurationMs(run),
1626
1627
  toolCalls: run.toolCalls,
1627
1628
  });
1628
1629
  return;
@@ -36,6 +36,10 @@ export type SendVerdict = {
36
36
  ok: false;
37
37
  reason: 'unknown_recipient';
38
38
  recipients: string[];
39
+ } | {
40
+ ok: false;
41
+ reason: 'recipient_finished';
42
+ who: string[];
39
43
  } | {
40
44
  ok: false;
41
45
  reason: 'agent_quota';
package/dist/team/bus.js CHANGED
@@ -103,10 +103,20 @@ export class TeamBus {
103
103
  send(from, to, subjectRaw, bodyRaw) {
104
104
  const target = to.trim().toLowerCase();
105
105
  const cost = target === ALL_KEY ? TEAM_LIMITS.broadcastCost : 1;
106
- const recipients = this.resolveRecipients(from, target);
107
- if (recipients.length === 0) {
106
+ const addressed = this.resolveRecipients(from, target);
107
+ if (addressed.length === 0) {
108
108
  return { ok: false, reason: 'unknown_recipient', recipients: this.participants() };
109
109
  }
110
+ // A finished peer has no loop left to drain its mailbox, so "Delivered" would be
111
+ // a false receipt and the quota spent on it a pure loss. Same fact `checkWaitable`
112
+ // already honours. The lead is always addressable: its mail is collected for the
113
+ // report, not read by a loop. Refused like every other refusal: nothing is spent
114
+ // and `lastSentAt` does not move. A peer that is about to finish but has not yet is
115
+ // still delivered to; that window cannot be closed without a lock.
116
+ const recipients = addressed.filter((r) => r === LEAD_KEY || this.canSend(r));
117
+ if (recipients.length === 0) {
118
+ return { ok: false, reason: 'recipient_finished', who: addressed };
119
+ }
110
120
  const used = this.sentCount(from);
111
121
  if (used + cost > TEAM_LIMITS.messagesPerAgent) {
112
122
  return { ok: false, reason: 'agent_quota', used, max: TEAM_LIMITS.messagesPerAgent };
@@ -64,6 +64,9 @@ export function makeTeamSend(bus, selfKey) {
64
64
  case 'unknown_recipient':
65
65
  return textResult(`Not sent: no subagent labelled "${to.trim()}". Teammates: ` +
66
66
  `${verdict.recipients.filter((r) => r !== selfKey).join(', ')}.`);
67
+ case 'recipient_finished':
68
+ return textResult(`Not sent: ${verdict.who.join(', ')} already finished and will not read it. ` +
69
+ 'Put what matters in your own final summary instead.');
67
70
  case 'agent_quota':
68
71
  return textResult(`Not sent: message quota spent (${verdict.used}/${verdict.max}). Finish your work ` +
69
72
  'and put it in your final summary.');
@@ -265,7 +265,7 @@ 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
269
  /**
270
270
  * The version string on `<forced_team_mode>`'s first line.
271
271
  *
@@ -279,7 +279,7 @@ 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
283
  // ---------------------------------------------------------------------------
284
284
  // Forced team mode (`/team <brief>`, team-command-forced-dispatch §6.5).
285
285
  //
@@ -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. */
@@ -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.
package/dist/ui/App.js CHANGED
@@ -55,6 +55,7 @@ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow
55
55
  // no policy. Imported beside the todo decision it shares one timer with.
56
56
  import { decideForcedDispatch, FORCED_NUDGE_NOTICE } from '../team/dispatch-gate.js';
57
57
  import { FORCED_NUDGE_GRACE_MS } from '../team/limits.js';
58
+ import { priceTeamUsage } from '../team/model.js';
58
59
  import { useTerminalSize } from './layout/useTerminalSize.js';
59
60
  import { useHeightStore } from './use-height-store.js';
60
61
  import { useRenderGovernor } from './use-render-governor.js';
@@ -929,21 +930,34 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
929
930
  // handler above records): this effect's dependency list never changes,
930
931
  // so it closes over the `cfg` object of the render it mounted in, and
931
932
  // `setModel` builds a NEW one.
932
- const live = controller.getConfig();
933
- // Every child runs the lead's own model (main-agent parity), so the
934
- // lead's live connection IS the tier that billed.
935
- const ref = { providerId: live.provider, modelId: live.model };
936
- // Unknown pricing contributes ZERO rather than a fabricated figure
937
- // (C-11 / RV-4); `/fast status` and the dispatch report say `unknown`.
938
- const costDelta = controller.isPricedModel(ref)
939
- ? computeCost(event.usage, controller.getModelInfoFor(ref).cost)
940
- : 0;
933
+ //
934
+ // PRICED AT THE MODEL THE CHILDREN RAN (`team.model`), through the same
935
+ // resolution the dispatch report prices with: under `fast` / `profile` the
936
+ // lead's table would over-report by up to an order of magnitude, and an
937
+ // unpriced child model must read as unknown (ZERO here), not as the lead's.
938
+ const costDelta = priceTeamUsage(controller.subagentModelResolution(), event.usage, {
939
+ mainCost: controller.getModelInfo().cost,
940
+ isPriced: (ref) => controller.isPricedModel(ref),
941
+ costFor: (ref) => controller.getModelInfoFor(ref).cost,
942
+ });
941
943
  dispatch({ type: 'teamUsage', usage: event.usage, costDelta });
942
944
  break;
943
945
  }
944
- case 'dispatch_end':
946
+ case 'dispatch_end': {
947
+ // The supervisor's own calls never emit a usage event (they go through the fast
948
+ // registry directly), so their spend reaches the readout HERE, once per
949
+ // dispatch, at the fast tier's price - the same channel review spend uses.
950
+ const spend = event.outcome.overseerUsage;
951
+ if (spend !== undefined && (spend.inputTokens > 0 || spend.outputTokens > 0)) {
952
+ const tier = controller.getFastStatus().tier;
953
+ const costDelta = tier.ok && controller.isPricedModel(tier.ref)
954
+ ? computeCost(spend, controller.getModelInfoFor(tier.ref).cost)
955
+ : 0;
956
+ dispatch({ type: 'fastUsage', usage: spend, costDelta });
957
+ }
945
958
  dispatch({ type: 'teamEnd', outcome: event.outcome });
946
959
  break;
960
+ }
947
961
  }
948
962
  });
949
963
  return () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.42",
3
+ "version": "0.6.43",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.54",
59
+ "@aragon-agent/core": "^0.2.55",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",