@yagni-app/code-staging 1.0.1-staging.1202.1 → 1.0.1-staging.1209.1

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.
@@ -29,6 +29,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
29
29
  import { type Component } from "@earendil-works/pi-tui";
30
30
  import { Type } from "typebox";
31
31
  import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
32
+ import type { ChildUsageHandle } from "./childUsage.js";
32
33
  import type { WorkingLineHandle } from "./workingLine.js";
33
34
  import { runStage as defaultRunStage } from "./pipeline/runner.js";
34
35
  import { type PipelineStage } from "./pipeline/types.js";
@@ -56,6 +57,12 @@ export interface MakeAskAdvisorToolOptions {
56
57
  * absent, the tool falls back to ui.setWorkingMessage directly.
57
58
  */
58
59
  workingLine?: WorkingLineHandle;
60
+ /**
61
+ * Session child-usage accumulator (childUsage.ts), so the footer's session
62
+ * totals include advisor consults — the spend the consult-cost line already
63
+ * prints. Absent = not wired (tests/harnesses): no recording.
64
+ */
65
+ childUsage?: ChildUsageHandle;
59
66
  }
60
67
  /**
61
68
  * Assemble the consult brief. The advisor's persona already tells it not to take
@@ -129,7 +129,7 @@ export function makeAskAdvisorTool(opts) {
129
129
  renderShell: "self",
130
130
  renderCall: renderAdvisorCall,
131
131
  renderResult: renderSubagentResult,
132
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
132
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
133
133
  // Read the LIVE session model: pi's picker can change it after this tool
134
134
  // was registered, in both directions.
135
135
  const decision = decideConsult({
@@ -194,6 +194,10 @@ export function makeAskAdvisorTool(opts) {
194
194
  const cost = result.usage?.cost ?? 0;
195
195
  const state = opts.state.record(cost);
196
196
  finalizeTask(progress, result, Date.now());
197
+ // Footer session totals include the consult. Keyed by toolCallId — one
198
+ // consult per call, unique even for parallel consults minted in the same
199
+ // millisecond — so a re-fired finalize for the same call is a no-op.
200
+ opts.childUsage?.record("advisor", toolCallId, result.usage);
197
201
  if (result.exitCode !== 0 && !result.finalOutput.trim()) {
198
202
  // Fail honestly rather than returning an empty recommendation. The
199
203
  // consult still counts: it spawned, and it may well have spent.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Session-scoped child-process usage accounting (YAG-383's honest-local
3
+ * counterpart for the FOOTER).
4
+ *
5
+ * `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
6
+ * the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
7
+ * every /go stage run in a child process whose usage never lands in those
8
+ * entries. The per-child surfaces (subagent receipts, /go's run-cost note,
9
+ * advisor's consult-cost line) already fold each child's usage from its NDJSON
10
+ * stream — this module is the session-level SUM of those already-held figures,
11
+ * so the footer's totals can include what the receipts already printed.
12
+ *
13
+ * Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
14
+ * snapshots, and an additive `record()`. Tokens ride along with cost so the
15
+ * footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
16
+ * (source, key): a re-fired completion for the same child must never
17
+ * double-count its spend, so each seam keys by the child's identity (one key
18
+ * per subagent task / consult / review / run attempt).
19
+ */
20
+ /** Usage fields the footer's line-2 stats render. */
21
+ export interface ChildUsageDelta {
22
+ input: number;
23
+ output: number;
24
+ cacheRead: number;
25
+ cacheWrite: number;
26
+ cost: number;
27
+ }
28
+ export interface ChildUsageHandle {
29
+ read(): ChildUsageDelta;
30
+ /**
31
+ * Add one child's usage, deduped by `(source, key)`. A repeated record for
32
+ * the same key is a no-op (re-fire safety), not an overwrite: child spend is
33
+ * cumulative and never revised downward. `delta` may be absent (a degraded
34
+ * or stubbed runner that reported no usage): the key is still consumed and
35
+ * nothing is added — never a throw.
36
+ */
37
+ record(source: string, key: string, delta: Partial<ChildUsageDelta> | undefined): ChildUsageDelta;
38
+ }
39
+ export declare function makeChildUsageState(): ChildUsageHandle;
40
+ //# sourceMappingURL=childUsage.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Session-scoped child-process usage accounting (YAG-383's honest-local
3
+ * counterpart for the FOOTER).
4
+ *
5
+ * `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
6
+ * the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
7
+ * every /go stage run in a child process whose usage never lands in those
8
+ * entries. The per-child surfaces (subagent receipts, /go's run-cost note,
9
+ * advisor's consult-cost line) already fold each child's usage from its NDJSON
10
+ * stream — this module is the session-level SUM of those already-held figures,
11
+ * so the footer's totals can include what the receipts already printed.
12
+ *
13
+ * Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
14
+ * snapshots, and an additive `record()`. Tokens ride along with cost so the
15
+ * footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
16
+ * (source, key): a re-fired completion for the same child must never
17
+ * double-count its spend, so each seam keys by the child's identity (one key
18
+ * per subagent task / consult / review / run attempt).
19
+ */
20
+ const num = (n) => {
21
+ const v = n ?? 0;
22
+ return Number.isFinite(v) && v > 0 ? v : 0;
23
+ };
24
+ export function makeChildUsageState() {
25
+ const seen = new Set();
26
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
27
+ return {
28
+ read: () => ({ ...totals }),
29
+ record(source, key, delta) {
30
+ const dedupeKey = `${source}\u0000${key}`;
31
+ if (seen.has(dedupeKey))
32
+ return { ...totals };
33
+ seen.add(dedupeKey);
34
+ totals.input += num(delta?.input);
35
+ totals.output += num(delta?.output);
36
+ totals.cacheRead += num(delta?.cacheRead);
37
+ totals.cacheWrite += num(delta?.cacheWrite);
38
+ totals.cost += num(delta?.cost);
39
+ return { ...totals };
40
+ },
41
+ };
42
+ }
43
+ //# sourceMappingURL=childUsage.js.map
@@ -35,6 +35,10 @@ export declare const DIFF_PREVIEW_LINES = 12;
35
35
  export declare const ERROR_TAIL_LINES = 5;
36
36
  /** Output tail lines shown under a still-running shell command. */
37
37
  export declare const PARTIAL_TAIL_LINES = 3;
38
+ /** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
39
+ * tool rows align with prose. Self-render rows bypass pi's pad (renderShell
40
+ * "self" → unpadded container), so we reapply it here. */
41
+ export declare const OUTPUT_PAD = 1;
38
42
  declare const BUILTIN_NAMES: readonly ["read", "bash", "edit", "write", "grep", "find", "ls"];
39
43
  type BuiltinName = (typeof BUILTIN_NAMES)[number];
40
44
  export type RowStatus = "running" | "ok" | "error";
@@ -41,6 +41,10 @@ export const PARTIAL_TAIL_LINES = 3;
41
41
  const EXPANDED_MAX_LINES = 1000;
42
42
  /** Title arg preview width. */
43
43
  const ARG_PREVIEW_MAX = 96;
44
+ /** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
45
+ * tool rows align with prose. Self-render rows bypass pi's pad (renderShell
46
+ * "self" → unpadded container), so we reapply it here. */
47
+ export const OUTPUT_PAD = 1;
44
48
  const BUILTIN_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
45
49
  const TITLE_BY_NAME = {
46
50
  read: "Read",
@@ -257,7 +261,7 @@ function empty() {
257
261
  return new Container();
258
262
  }
259
263
  function textComponent(lines) {
260
- return lines.length === 0 ? empty() : new Text(lines.join("\n"), 0, 0);
264
+ return lines.length === 0 ? empty() : new Text(lines.join("\n"), OUTPUT_PAD, 0);
261
265
  }
262
266
  function defaultLoadSettings(cwd) {
263
267
  return SettingsManager.create(cwd, getAgentDir());
@@ -332,13 +336,13 @@ export function registerCondensedTools(pi, deps = {}) {
332
336
  const status = slice.isError ? "error" : slice.isPartial ? "running" : "ok";
333
337
  const argsRecord = args;
334
338
  if (slice.expanded || !isQuiet(row)) {
335
- return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), 0, 0);
339
+ return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), OUTPUT_PAD, 0);
336
340
  }
337
341
  if (!row.final) {
338
- return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), 0, 0);
342
+ return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), OUTPUT_PAD, 0);
339
343
  }
340
344
  const summary = tracker.summaryFor(row.id);
341
- return summary ? new Text(theme.fg("muted", summary), 0, 0) : empty();
345
+ return summary ? new Text(theme.fg("muted", summary), OUTPUT_PAD, 0) : empty();
342
346
  },
343
347
  renderResult(result, options, theme, context) {
344
348
  const slice = context;
@@ -39,6 +39,7 @@
39
39
  * — useful reference for what data to replicate and how to format it.
40
40
  */
41
41
  import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
42
+ import type { ChildUsageHandle } from "./childUsage.js";
42
43
  import type { ModeHolder, PermissionMode } from "./permission/gate.js";
43
44
  export declare const BRANCH_MAX_WIDTH = 60;
44
45
  export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
@@ -99,6 +100,17 @@ export declare function renderFooterLines(input: {
99
100
  /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
101
  diff?: string | null;
101
102
  usage: UsageTotals;
103
+ /**
104
+ * Session child-process spend (subagents, advisor consults, Guardian
105
+ * reviews, /go runs) — childUsage.ts's session accumulator. Added into
106
+ * the line-2 stats so the footer's totals include the spend the per-child
107
+ * receipts already print; absent/zero changes nothing.
108
+ */
109
+ childUsage?: {
110
+ input: number;
111
+ output: number;
112
+ cost: number;
113
+ };
102
114
  contextPercent: number | null;
103
115
  statuses: string[];
104
116
  }, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
@@ -111,7 +123,7 @@ export interface FooterInvalidateHandle {
111
123
  invalidateGit(): void;
112
124
  requestRender(): void;
113
125
  }
114
- export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
126
+ export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle, childUsage?: ChildUsageHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
115
127
  render(width: number): string[];
116
128
  invalidate(): void;
117
129
  dispose(): void;
@@ -245,13 +245,19 @@ export function renderFooterLines(input, theme, width, padX = 0) {
245
245
  }
246
246
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
247
247
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
248
+ // Child-process spend (subagents, advisor, /go) joins the driver totals so
249
+ // the footer matches the per-child receipts and /cost's session scope.
250
+ const child = input.childUsage;
251
+ const totalIn = input.usage.input + (child?.input ?? 0);
252
+ const totalOut = input.usage.output + (child?.output ?? 0);
253
+ const totalCost = input.usage.cost + (child?.cost ?? 0);
248
254
  const statParts = [];
249
- if (input.usage.input)
250
- statParts.push(`↑${formatTokens(input.usage.input)}`);
251
- if (input.usage.output)
252
- statParts.push(`↓${formatTokens(input.usage.output)}`);
253
- if (input.usage.cost)
254
- statParts.push(`$${input.usage.cost.toFixed(3)}`);
255
+ if (totalIn)
256
+ statParts.push(`↑${formatTokens(totalIn)}`);
257
+ if (totalOut)
258
+ statParts.push(`↓${formatTokens(totalOut)}`);
259
+ if (totalCost)
260
+ statParts.push(`$${totalCost.toFixed(3)}`);
255
261
  const stats = statParts.join(" ");
256
262
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
257
263
  const line2Parts = [];
@@ -272,7 +278,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
272
278
  }
273
279
  return lines;
274
280
  }
275
- export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
281
+ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, childUsage) {
276
282
  return (_tui, theme, footerData) => {
277
283
  let gitCache;
278
284
  let diffStatCache;
@@ -305,6 +311,7 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
305
311
  mode: modeHolder?.get() ?? null,
306
312
  diff: formatDiffStat(diffStatCache?.get() ?? null),
307
313
  usage: collectUsage(ctx.sessionManager),
314
+ childUsage: childUsage?.read(),
308
315
  contextPercent: ctx.getContextUsage()?.percent ?? null,
309
316
  statuses,
310
317
  }, theme, width, resolveFooterPadX());
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
4
+ import { makeChildUsageState } from "./childUsage.js";
4
5
  import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
5
6
  import { redactCommand } from "./redact.js";
6
7
  import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
@@ -187,7 +188,11 @@ export async function registerYagni(pi, deps = {}) {
187
188
  // hide it forever from a session that switched TO Advanced. One state handle
188
189
  // per session, shared with /advise so they draw on the same cap.
189
190
  const advisorState = makeAdvisorState();
190
- const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine });
191
+ // Session child-usage accumulator: every child process's spend (subagent
192
+ // tasks, advisor consults, Guardian reviews, /go runs) lands here so the
193
+ // footer's session totals include what the per-child receipts print.
194
+ const childUsage = makeChildUsageState();
195
+ const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine, childUsage });
191
196
  pi.registerTool(askAdvisorTool);
192
197
  // /advise runs the SAME tool, sharing the state handle, so a manual consult
193
198
  // draws on the same cap rather than opening a side channel around it.
@@ -232,7 +237,7 @@ export async function registerYagni(pi, deps = {}) {
232
237
  // driver's delegation identity for the diamond directive (see the
233
238
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
234
239
  const ultraHolder = createUltraHolder();
235
- registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine });
240
+ registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
236
241
  registerUltraCommand(pi, ultraHolder);
237
242
  // Shared fetch timeout for the small, interactive display-path reads below
238
243
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -242,6 +247,7 @@ export async function registerYagni(pi, deps = {}) {
242
247
  // The grounded multi-agent pipeline entry point: /go <ticket> runs
243
248
  // map → plan → implement → review → fix, each child grounded by inheritance.
244
249
  registerGoCommand(pi, {
250
+ childUsage,
245
251
  // /ultra is one dial for the whole session: the same holder the subagent
246
252
  // tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
247
253
  // the fan and its fix turns. Read per run, so a toggle lands on the next /go.
@@ -359,6 +365,7 @@ export async function registerYagni(pi, deps = {}) {
359
365
  guardianLimits,
360
366
  guardianTier,
361
367
  guardianDisabled,
368
+ childUsage,
362
369
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
363
370
  onGuardianReview: (ev) => {
364
371
  guardianLogSink(ev);
@@ -900,7 +907,7 @@ export async function registerYagni(pi, deps = {}) {
900
907
  // context % on line 2, and extension statuses (brand, todos, mode) on
901
908
  // line 3. The factory captures ctx so the footer can read session data
902
909
  // (token stats, context usage) that isn't on the footerData provider.
903
- ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
910
+ ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
904
911
  ctx.ui?.onTerminalInput?.((data) => {
905
912
  if (isShiftTab(data)) {
906
913
  modeHolder.set(cyclePermissionMode(modeHolder.get()));
@@ -151,6 +151,12 @@ export interface RegisterPermissionDeps {
151
151
  guardianState?: import("./guardian.js").GuardianStateHandle;
152
152
  /** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
153
153
  guardianLimits?: import("./guardian.js").GuardianLimits;
154
+ /**
155
+ * Session child-usage accumulator (childUsage.ts): each completed Guardian
156
+ * review's spend is recorded so the footer's session totals include it.
157
+ * Absent = not wired: no recording, nothing else changes.
158
+ */
159
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
154
160
  /** Override the Guardian model tier (default: efficient). */
155
161
  guardianTier?: string;
156
162
  /** Injectable Guardian review function (tests pass a stub). */
@@ -366,6 +366,10 @@ export function registerPermissionGate(pi, deps = {}) {
366
366
  const ERROR_ASK_CAP = 3;
367
367
  let errorFallbackAsks = 0;
368
368
  let breakerEscalationOffered = false;
369
+ // Uniquifies each Guardian review's childUsage key: parallel gated tool
370
+ // calls can start their reviews in the same millisecond, and a timestamp
371
+ // alone would silently drop the second review's spend as a "duplicate".
372
+ let reviewSpendSeq = 0;
369
373
  const emitGateEvent = (event) => {
370
374
  if (!deps.onGuardianEvent)
371
375
  return;
@@ -561,6 +565,10 @@ export function registerPermissionGate(pi, deps = {}) {
561
565
  ctx.ui.setStatus?.("yagni-guardian", undefined);
562
566
  }
563
567
  const durationMs = Date.now() - startMs;
568
+ // Footer session totals include the review's spend. Each review
569
+ // records exactly once, right here, so the key only has to be unique
570
+ // per review — the seq keeps same-millisecond parallel reviews apart.
571
+ deps.childUsage?.record("guardian", `${startMs}#${reviewSpendSeq++}`, { cost: reviewResult.cost });
564
572
  const emitDiag = (outcome, rationale, rawOutput) => {
565
573
  if (!deps.onGuardianReview)
566
574
  return;
@@ -110,6 +110,14 @@ export interface RegisterGoDeps {
110
110
  * cares injects its own fake.
111
111
  */
112
112
  fetchRunSpend?: (runId: string, signal?: AbortSignal) => Promise<SpendResponse | null>;
113
+ /**
114
+ * Session child-usage accumulator (childUsage.ts). Each attempt's newly-run
115
+ * stage usage is recorded (keyed by runId + attempt start, because a resumed
116
+ * attempt's stages are new spend, never a replay) so the footer's session
117
+ * totals include /go spend — the same figure `runCostNote` already prints.
118
+ * Absent = not wired.
119
+ */
120
+ childUsage?: import("../childUsage.js").ChildUsageHandle;
113
121
  /** Injectable fs existence check (worktree adoption on resume). */
114
122
  exists?: (path: string) => boolean;
115
123
  /** Clock seam for registry rows + staleness. */
@@ -969,6 +969,14 @@ export function registerGoCommand(pi, deps = {}) {
969
969
  if (result.stopReason === "clean" && runSignal.aborted) {
970
970
  result = { ...result, stopReason: "aborted" };
971
971
  }
972
+ // Footer session totals include this run's spend. Keyed per ATTEMPT
973
+ // (runId + this invocation's start), not per runId: `result.stages`
974
+ // only ever holds the stages THIS attempt actually ran (a resume's
975
+ // replayed spend lives in the checkpoint's priorUsage, never here),
976
+ // so an abort → same-session resume must record both attempts — a
977
+ // runId-only key would silently drop the resume's new spend. Partial
978
+ // spend on an aborted attempt is still real spend worth counting.
979
+ deps.childUsage?.record("go", `${runId}@${startedAt}`, aggregateRunUsage(result.stages, result.rounds));
972
980
  // FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
973
981
  // (worktree always; --here only over a clean pre-run baseline) with the
974
982
  // provenance trailer, and push + PR when --pr asked for it. Iron
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { Type } from "typebox";
22
+ import type { ChildUsageHandle } from "./childUsage.js";
22
23
  import type { WorkingLineHandle } from "./workingLine.js";
23
24
  import { runStage } from "./pipeline/runner.js";
24
25
  import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
@@ -103,6 +104,13 @@ export interface MakeSubagentToolDeps {
103
104
  * the tool falls back to setting ui.setWorkingMessage directly.
104
105
  */
105
106
  workingLine?: WorkingLineHandle;
107
+ /**
108
+ * Session child-usage accumulator (childUsage.ts). Each completed task's
109
+ * folded usage is recorded so the footer's session totals include the
110
+ * child spend the per-task receipt already prints. Absent = not wired
111
+ * (headless/eval harnesses): no recording, nothing else changes.
112
+ */
113
+ childUsage?: ChildUsageHandle;
106
114
  }
107
115
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
108
116
  name: string;
@@ -120,7 +128,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
120
128
  renderShell: "self";
121
129
  renderCall: typeof renderSubagentCall;
122
130
  renderResult: typeof renderSubagentResult;
123
- execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
131
+ execute(toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
124
132
  content: Array<{
125
133
  type: "text";
126
134
  text: string;
@@ -151,6 +159,8 @@ export interface RegisterSubagentsDeps {
151
159
  isUltra?: () => boolean;
152
160
  /** Session working-line manager; see MakeSubagentToolDeps.workingLine. */
153
161
  workingLine?: WorkingLineHandle;
162
+ /** Session child-usage accumulator; see MakeSubagentToolDeps.childUsage. */
163
+ childUsage?: ChildUsageHandle;
154
164
  }
155
165
  /** Wire the subagent tool and the /agents listing command. */
156
166
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
@@ -33,6 +33,17 @@ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, p
33
33
  * within the model proxy's 64-char caller-label limit.
34
34
  */
35
35
  const SUBAGENT_CALLER_PREFIX = "subagent:";
36
+ /**
37
+ * Dedupe key for one task's childUsage record. Neither `startedAt` (parallel
38
+ * tasks on the same agent are minted in the same `resolved.map` pass and can
39
+ * share a millisecond) nor the per-invocation index (every invocation starts
40
+ * at 0) is unique alone; the pair is, because one tool call's task set is
41
+ * minted exactly once. A re-fired finalize for the same record lands on the
42
+ * same key, so the accumulator's (source, key) dedupe makes it a no-op.
43
+ */
44
+ function progressKey(toolCallId, p, index) {
45
+ return `${toolCallId}/${index}/${p.agent}@${p.startedAt}`;
46
+ }
36
47
  export const SUBAGENT_TOOL_NAME = "subagent";
37
48
  export const GENERAL_AGENT_NAME = "general";
38
49
  export const MAX_PARALLEL_SUBAGENTS = 4;
@@ -354,7 +365,7 @@ export function makeSubagentTool(deps = {}) {
354
365
  renderShell: "self",
355
366
  renderCall: renderSubagentCall,
356
367
  renderResult: renderSubagentResult,
357
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
368
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
358
369
  const fail = (text) => ({
359
370
  content: [{ type: "text", text }],
360
371
  details: {},
@@ -428,6 +439,10 @@ export function makeSubagentTool(deps = {}) {
428
439
  },
429
440
  });
430
441
  finalizeTask(progress, result, Date.now());
442
+ // Record the child's spend into the session accumulator so the
443
+ // footer totals include it (dedupe key: this progress record's
444
+ // identity — per invocation, so parallel tasks never collide).
445
+ deps.childUsage?.record("subagent", progressKey(toolCallId, progress, index), result.usage);
431
446
  emit();
432
447
  return { agent: def.name, task, result };
433
448
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.1-staging.1202.1",
3
+ "version": "1.0.1-staging.1209.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -41,5 +41,5 @@
41
41
  "turndown": "^7.2.4",
42
42
  "typebox": "^1.3.15"
43
43
  },
44
- "yagniSourceSha": "2455348d067f7038017822f6d36de33d3058e77a"
44
+ "yagniSourceSha": "4c17b9d1e5447f1be919fa76b3f82d1d92620a1c"
45
45
  }