@selesai/code 0.13.32 → 0.13.34

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 (65) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/extensions/capability-gateway/catalog.ts +4 -1
  3. package/dist/extensions/capability-gateway/index.test.ts +93 -4
  4. package/dist/extensions/capability-gateway/index.ts +294 -59
  5. package/dist/extensions/capability-gateway/integration.test.ts +71 -0
  6. package/dist/extensions/capability-gateway/routing.test.ts +154 -1
  7. package/dist/extensions/capability-gateway/routing.ts +227 -45
  8. package/dist/extensions/jev/decisions.test.ts +37 -0
  9. package/dist/extensions/jev/decisions.ts +148 -43
  10. package/dist/extensions/jev-ask-tool.test.ts +436 -0
  11. package/dist/extensions/jev-ask-tool.ts +587 -0
  12. package/dist/extensions/package.json +1 -0
  13. package/dist/extensions/pi-hermes-memory/README.md +11 -36
  14. package/dist/extensions/pi-hermes-memory/src/config.ts +36 -8
  15. package/dist/extensions/pi-hermes-memory/src/constants.ts +5 -5
  16. package/dist/extensions/pi-hermes-memory/src/handlers/auto-consolidate.ts +60 -46
  17. package/dist/extensions/pi-hermes-memory/tests/config.test.ts +26 -2
  18. package/dist/extensions/pi-hermes-memory/tests/handlers/auto-consolidate.test.ts +7 -1
  19. package/dist/extensions/pi-intercom/index.ts +5 -1
  20. package/dist/extensions/pi-subagents/agents/worker.md +3 -2
  21. package/dist/extensions/pi-subagents/docs/agents.md +2 -0
  22. package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
  23. package/dist/extensions/pi-subagents/docs/observability.md +2 -0
  24. package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
  25. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  26. package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
  27. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
  28. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
  29. package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
  30. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  31. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
  32. package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
  33. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
  34. package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
  35. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
  36. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
  37. package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
  38. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
  39. package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
  40. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
  41. package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
  42. package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
  43. package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
  44. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
  45. package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
  46. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
  47. package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
  48. package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
  49. package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
  50. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
  51. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
  52. package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
  53. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
  54. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
  55. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
  56. package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
  57. package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
  58. package/dist/extensions/rtk.test.ts +21 -13
  59. package/dist/extensions/tps.test.ts +32 -1
  60. package/dist/extensions/tps.ts +3 -1
  61. package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
  62. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  63. package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
  64. package/docs/settings.md +46 -7
  65. package/package.json +3 -3
@@ -17,17 +17,17 @@ import {
17
17
  } from "../profiles/profiles.ts";
18
18
  import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts";
19
19
  import { findModelInfo, toModelInfo } from "../shared/model-info.ts";
20
- import { formatTokens, shortenPath } from "../shared/formatters.ts";
20
+ import { shortenPath } from "../shared/formatters.ts";
21
21
  import { listAsyncRuns, formatAsyncRunProgressLabel, type AsyncRunSummary } from "../runs/background/async-status.ts";
22
22
  import { encodeInspectReply, handleInspectRpcArgs, INSPECT_WIDGET_KEY } from "../runs/background/inspect-rpc.ts";
23
23
  import { listScheduledRunSummaries } from "../runs/background/scheduled-runs.ts";
24
24
  import { resolveAsyncStatusChild } from "../runs/shared/child-identity.ts";
25
25
  import { readStatus } from "../shared/utils.ts";
26
- import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
27
- import { readWorkflowReceipt } from "../workflows/workflow-receipt.ts";
26
+
28
27
  import { FLEET_OPEN_SHORTCUT } from "../shared/shortcuts.ts";
29
28
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
30
29
  import { registerPromptWorkflowCommands } from "./prompt-workflows.ts";
30
+ import { collectSubagentCost, formatSubagentCostReport } from "./subagent-cost.ts";
31
31
  import { openSubagentsAdmin } from "./subagents-admin.ts";
32
32
  import { SUBAGENT_GUIDE_TOPICS } from "../extension/subagent-guide.ts";
33
33
  import { openSubagentFleet } from "../tui/fleet.ts";
@@ -36,7 +36,6 @@ import {
36
36
  buildSlashInitialResult,
37
37
  failSlashResult,
38
38
  finalizeSlashResult,
39
- resolveSlashMessageDetails,
40
39
  } from "./slash-live-state.ts";
41
40
  import {
42
41
  SLASH_RESULT_TYPE,
@@ -47,11 +46,9 @@ import {
47
46
  SLASH_SUBAGENT_STARTED_EVENT,
48
47
  SLASH_SUBAGENT_UPDATE_EVENT,
49
48
  DIRS,
50
- type Details,
51
49
  type FleetKeybindingsConfig,
52
50
  type SingleResult,
53
51
  type SubagentState,
54
- type Usage,
55
52
  } from "../shared/types.ts";
56
53
 
57
54
  interface InlineConfig {
@@ -328,237 +325,6 @@ class SubagentsStopSelector implements Component {
328
325
  }
329
326
  }
330
327
 
331
- function emptyUsage(): Usage {
332
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
333
- }
334
-
335
- function addUsage(target: Usage, source: Usage): void {
336
- target.input += source.input;
337
- target.output += source.output;
338
- target.cacheRead += source.cacheRead;
339
- target.cacheWrite += source.cacheWrite;
340
- target.cost += source.cost;
341
- target.turns += source.turns;
342
- }
343
-
344
- function usageHasValue(usage: Usage): boolean {
345
- return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
346
- }
347
-
348
- function nonNegativeNumber(value: unknown): number | undefined {
349
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
350
- }
351
-
352
- function usageFromValue(value: unknown, turnsOverride?: number): Usage | undefined {
353
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
354
- const record = value as Record<string, unknown>;
355
- const costValue = typeof record.cost === "object" && record.cost !== null && !Array.isArray(record.cost)
356
- ? (record.cost as Record<string, unknown>).total
357
- : record.cost;
358
- const turnsValue = turnsOverride ?? record.turns;
359
- const usage = {
360
- input: nonNegativeNumber(record.input) ?? 0,
361
- output: nonNegativeNumber(record.output) ?? 0,
362
- cacheRead: nonNegativeNumber(record.cacheRead) ?? 0,
363
- cacheWrite: nonNegativeNumber(record.cacheWrite) ?? 0,
364
- cost: nonNegativeNumber(costValue) ?? 0,
365
- turns: nonNegativeNumber(turnsValue) ?? 0,
366
- };
367
- return Number.isSafeInteger(usage.turns) ? usage : undefined;
368
- }
369
-
370
- function assistantUsageFromMessage(message: unknown): Usage | undefined {
371
- if (!message || typeof message !== "object") return undefined;
372
- const msg = message as { role?: unknown; usage?: unknown };
373
- return msg.role === "assistant" ? usageFromValue(msg.usage, 1) : undefined;
374
- }
375
-
376
- function compactionUsageFromEntry(entry: unknown): Usage | undefined {
377
- if (!entry || typeof entry !== "object") return undefined;
378
- const record = entry as { type?: unknown; usage?: unknown };
379
- return record.type === "compaction" ? usageFromValue(record.usage, 0) : undefined;
380
- }
381
-
382
- const MAX_USAGE_METADATA_BYTES = 2 * 1024 * 1024;
383
-
384
- function readUsageMetadata(metadataPath: string): Record<string, unknown> | undefined {
385
- let fd: number | undefined;
386
- try {
387
- fd = fs.openSync(metadataPath, "r");
388
- const stat = fs.fstatSync(fd);
389
- if (!stat.isFile() || stat.size > MAX_USAGE_METADATA_BYTES) return undefined;
390
- const buffer = Buffer.allocUnsafe(stat.size);
391
- let offset = 0;
392
- while (offset < buffer.length) {
393
- const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
394
- if (bytesRead <= 0) return undefined;
395
- offset += bytesRead;
396
- }
397
- const value: unknown = JSON.parse(buffer.toString("utf-8"));
398
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
399
- } finally {
400
- if (fd !== undefined) fs.closeSync(fd);
401
- }
402
- }
403
-
404
- function isSubagentDetails(value: unknown): value is Details {
405
- if (!value || typeof value !== "object") return false;
406
- const details = value as { mode?: unknown; results?: unknown };
407
- return typeof details.mode === "string" && Array.isArray(details.results);
408
- }
409
-
410
- function detailsFromSessionEntry(entry: unknown): Details | undefined {
411
- if (!entry || typeof entry !== "object") return undefined;
412
- const record = entry as { type?: unknown; customType?: unknown; details?: unknown; message?: unknown };
413
- if (record.type === "custom_message" && record.customType === SLASH_RESULT_TYPE) {
414
- const details = resolveSlashMessageDetails(record.details)?.result.details;
415
- return isSubagentDetails(details) ? details : undefined;
416
- }
417
- if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
418
- const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
419
- if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait")) return undefined;
420
- return isSubagentDetails(message.details) ? message.details : undefined;
421
- }
422
-
423
- function metadataUsage(
424
- artifactsDirs: string[],
425
- input: { runId: string; agent: string },
426
- ): Usage | undefined {
427
- if (!/^[A-Za-z0-9._-]+$/.test(input.runId)) return undefined;
428
- for (const artifactsDir of artifactsDirs) {
429
- for (const index of [0, undefined] as const) {
430
- const metadataPath = getArtifactPaths(artifactsDir, input.runId, input.agent, index).metadataPath;
431
- try {
432
- const metadata = readUsageMetadata(metadataPath);
433
- if (!metadata) continue;
434
- if (metadata.runId !== input.runId || metadata.agent !== input.agent) continue;
435
- const usage = usageFromValue(metadata.usage);
436
- if (usage && usageHasValue(usage)) return usage;
437
- } catch (error) {
438
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to read subagent usage metadata '${metadataPath}':`, error);
439
- }
440
- }
441
- }
442
- return undefined;
443
- }
444
-
445
- function formatCostUsage(label: string, usage: Usage): string {
446
- const extras = [
447
- usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
448
- usage.cacheWrite ? `cache write ${formatTokens(usage.cacheWrite)}` : "",
449
- usage.turns ? `${usage.turns} turn${usage.turns === 1 ? "" : "s"}` : "",
450
- ].filter(Boolean);
451
- return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
452
- }
453
-
454
- function buildSubagentCostReport(ctx: ExtensionContext, state: SubagentState): string {
455
- const parent = emptyUsage();
456
- const childTotal = emptyUsage();
457
- const total = emptyUsage();
458
- const children: Array<{ label: string; usage: Usage; sessionFile?: string }> = [];
459
- const seenChildren = new Set<string>();
460
- const workflowRunIds = new Set<string>();
461
- let unresolvedAsyncChildren = 0;
462
-
463
- const addChild = (input: { agent?: string; runId?: string; usage?: Usage; sessionFile?: string }): boolean => {
464
- if (!input.usage || !usageHasValue(input.usage)) return false;
465
- const identity = input.runId ? `run:${input.runId}` : input.sessionFile ? `session:${input.sessionFile}` : undefined;
466
- if (identity && seenChildren.has(identity)) return true;
467
- if (identity) seenChildren.add(identity);
468
- const usage = { ...input.usage };
469
- children.push({
470
- label: `Child ${children.length + 1} (${input.agent ?? "unknown"})`,
471
- usage,
472
- ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
473
- });
474
- addUsage(childTotal, usage);
475
- return true;
476
- };
477
-
478
- for (const entry of ctx.sessionManager.getBranch()) {
479
- const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
480
- const parentUsage = assistantUsageFromMessage(message) ?? compactionUsageFromEntry(entry);
481
- if (parentUsage) addUsage(parent, parentUsage);
482
- const details = detailsFromSessionEntry(entry);
483
- if (!details) continue;
484
- if (details.mode === "workflow" && details.runId) workflowRunIds.add(details.runId);
485
- for (const result of details.results) {
486
- const resultRunId = (result as SingleResult & { runId?: unknown }).runId;
487
- addChild({ agent: result.agent, runId: typeof resultRunId === "string" ? resultRunId : undefined, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
488
- }
489
- for (const completion of details.completions ?? []) {
490
- if (completion.mode === "workflow") workflowRunIds.add(completion.runId);
491
- for (const result of completion.results ?? []) {
492
- addChild({ agent: result.agent, runId: result.runId, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
493
- }
494
- }
495
- }
496
-
497
- let sessionFile: string | null = null;
498
- try {
499
- sessionFile = ctx.sessionManager.getSessionFile() ?? null;
500
- } catch { /* an unpersisted session can still report direct child usage */ }
501
- const artifactsDirs = new Set<string>();
502
- const addArtifactsDir = (cwd: string | undefined): void => {
503
- try {
504
- artifactsDirs.add(getArtifactsDir(sessionFile, cwd, state.artifactDirPreference));
505
- } catch { /* invalid or unavailable project roots are ignored */ }
506
- };
507
- addArtifactsDir(ctx.cwd);
508
- addArtifactsDir(state.baseCwd);
509
-
510
- for (const workflowRunId of workflowRunIds) {
511
- try {
512
- const status = readStatus(path.join(DIRS.async, workflowRunId));
513
- addArtifactsDir(status?.cwd);
514
- const receipt = readWorkflowReceipt(DIRS.async, workflowRunId);
515
- const summaryChildren = new Map((receipt.workflowChildren?.children ?? []).map((child) => [child.childId, child]));
516
- const refsByRunId = new Map<string, { runId: string; agent: string }>();
517
- const addRef = (runId: string | undefined, agent: string | undefined): void => {
518
- if (!runId || !agent || refsByRunId.has(runId)) return;
519
- refsByRunId.set(runId, { runId, agent });
520
- };
521
- for (const [key, entry] of Object.entries(receipt.entries)) {
522
- const summaryChild = summaryChildren.get(key);
523
- const runIds = entry.continuation?.runIds?.length
524
- ? entry.continuation.runIds
525
- : entry.latestRunId ? [entry.latestRunId] : summaryChild?.runId ? [summaryChild.runId] : [];
526
- for (const runId of runIds) addRef(runId, entry.agent ?? summaryChild?.agent);
527
- }
528
- for (const child of receipt.workflowChildren?.children ?? []) {
529
- if (!Object.hasOwn(receipt.entries, child.childId)) addRef(child.runId, child.agent);
530
- }
531
- const refs = [...refsByRunId.values()];
532
- for (const ref of refs) {
533
- if (seenChildren.has(`run:${ref.runId}`)) continue;
534
- const usage = metadataUsage([...artifactsDirs], ref);
535
- if (!usage || !addChild({ ...ref, usage })) unresolvedAsyncChildren += 1;
536
- }
537
- } catch (error) {
538
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to resolve async subagent usage for '${workflowRunId}':`, error);
539
- }
540
- }
541
-
542
- addUsage(total, parent);
543
- addUsage(total, childTotal);
544
- const lines = [
545
- "Subagent cost",
546
- "",
547
- formatCostUsage("Parent", parent),
548
- ];
549
- if (children.length === 0) {
550
- lines.push("No subagent child usage found in this session.");
551
- } else {
552
- for (const child of children) {
553
- lines.push(formatCostUsage(child.label, child.usage));
554
- if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
555
- }
556
- }
557
- if (unresolvedAsyncChildren > 0) lines.push(`Async child usage unavailable: ${unresolvedAsyncChildren}.`);
558
- lines.push("────────────────────────────", formatCostUsage("Children", childTotal), formatCostUsage("Total", total));
559
- return lines.join("\n");
560
- }
561
-
562
328
  function parseSingleRequiredArg(args: string, usage: string): { ok: true; value: string } | { ok: false; message: string } {
563
329
  const parts = args.trim().split(/\s+/).filter(Boolean);
564
330
  if (parts.length !== 1) return { ok: false, message: usage };
@@ -923,7 +689,7 @@ export function registerSlashCommands(
923
689
  pi.registerCommand("subagent-cost", {
924
690
  description: "Show parent and subagent child usage cost for this session",
925
691
  handler: async (_args, ctx) => {
926
- sendSlashText(pi, buildSubagentCostReport(ctx, state));
692
+ sendSlashText(pi, formatSubagentCostReport(collectSubagentCost(ctx, state)));
927
693
  },
928
694
  });
929
695
 
@@ -0,0 +1,280 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "@selesai/code";
4
+ import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
5
+ import { formatTokens } from "../shared/formatters.ts";
6
+ import { DIRS, SLASH_RESULT_TYPE, type Details, type SingleResult, type SubagentState, type Usage } from "../shared/types.ts";
7
+ import { readStatus } from "../shared/utils.ts";
8
+ import { readWorkflowReceipt } from "../workflows/workflow-receipt.ts";
9
+ import { resolveSlashMessageDetails } from "./slash-live-state.ts";
10
+
11
+ /** Structured parent-plus-child accounting for one session; the source of `/subagent-cost` and the RPC `cost` method. */
12
+ export interface SubagentCostReport {
13
+ version: 1;
14
+ parent: Usage;
15
+ children: SubagentCostChild[];
16
+ childTotal: Usage;
17
+ total: Usage;
18
+ /** Async children whose usage metadata could not be resolved; the child total is a lower bound when this is non-zero. */
19
+ unresolvedAsyncChildren: number;
20
+ }
21
+
22
+ export interface SubagentCostChild {
23
+ label: string;
24
+ agent?: string;
25
+ runId?: string;
26
+ usage: Usage;
27
+ sessionFile?: string;
28
+ }
29
+
30
+ export const SUBAGENT_COST_REPORT_VERSION = 1 as const;
31
+
32
+ function emptyUsage(): Usage {
33
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
34
+ }
35
+
36
+ function addUsage(target: Usage, source: Usage): void {
37
+ target.input += source.input;
38
+ target.output += source.output;
39
+ target.cacheRead += source.cacheRead;
40
+ target.cacheWrite += source.cacheWrite;
41
+ target.cost += source.cost;
42
+ target.turns += source.turns;
43
+ }
44
+
45
+ function usageHasValue(usage: Usage): boolean {
46
+ return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
47
+ }
48
+
49
+ function nonNegativeNumber(value: unknown): number | undefined {
50
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
51
+ }
52
+
53
+ function usageFromValue(value: unknown, turnsOverride?: number): Usage | undefined {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ const record = value as Record<string, unknown>;
56
+ const costValue = typeof record.cost === "object" && record.cost !== null && !Array.isArray(record.cost)
57
+ ? (record.cost as Record<string, unknown>).total
58
+ : record.cost;
59
+ const turnsValue = turnsOverride ?? record.turns;
60
+ const usage = {
61
+ input: nonNegativeNumber(record.input) ?? 0,
62
+ output: nonNegativeNumber(record.output) ?? 0,
63
+ cacheRead: nonNegativeNumber(record.cacheRead) ?? 0,
64
+ cacheWrite: nonNegativeNumber(record.cacheWrite) ?? 0,
65
+ cost: nonNegativeNumber(costValue) ?? 0,
66
+ turns: nonNegativeNumber(turnsValue) ?? 0,
67
+ };
68
+ return Number.isSafeInteger(usage.turns) ? usage : undefined;
69
+ }
70
+
71
+ function assistantUsageFromMessage(message: unknown): Usage | undefined {
72
+ if (!message || typeof message !== "object") return undefined;
73
+ const msg = message as { role?: unknown; usage?: unknown };
74
+ return msg.role === "assistant" ? usageFromValue(msg.usage, 1) : undefined;
75
+ }
76
+
77
+ function compactionUsageFromEntry(entry: unknown): Usage | undefined {
78
+ if (!entry || typeof entry !== "object") return undefined;
79
+ const record = entry as { type?: unknown; usage?: unknown };
80
+ return record.type === "compaction" ? usageFromValue(record.usage, 0) : undefined;
81
+ }
82
+
83
+ const MAX_USAGE_METADATA_BYTES = 2 * 1024 * 1024;
84
+
85
+ function readUsageMetadata(metadataPath: string): Record<string, unknown> | undefined {
86
+ let fd: number | undefined;
87
+ try {
88
+ fd = fs.openSync(metadataPath, "r");
89
+ const stat = fs.fstatSync(fd);
90
+ if (!stat.isFile() || stat.size > MAX_USAGE_METADATA_BYTES) return undefined;
91
+ const buffer = Buffer.allocUnsafe(stat.size);
92
+ let offset = 0;
93
+ while (offset < buffer.length) {
94
+ const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
95
+ if (bytesRead <= 0) return undefined;
96
+ offset += bytesRead;
97
+ }
98
+ const value: unknown = JSON.parse(buffer.toString("utf-8"));
99
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
100
+ } finally {
101
+ if (fd !== undefined) fs.closeSync(fd);
102
+ }
103
+ }
104
+
105
+ function isSubagentDetails(value: unknown): value is Details {
106
+ if (!value || typeof value !== "object") return false;
107
+ const details = value as { mode?: unknown; results?: unknown };
108
+ return typeof details.mode === "string" && Array.isArray(details.results);
109
+ }
110
+
111
+ function detailsFromSessionEntry(entry: unknown): Details | undefined {
112
+ if (!entry || typeof entry !== "object") return undefined;
113
+ const record = entry as { type?: unknown; customType?: unknown; details?: unknown; message?: unknown };
114
+ if (record.type === "custom_message" && record.customType === SLASH_RESULT_TYPE) {
115
+ const details = resolveSlashMessageDetails(record.details)?.result.details;
116
+ return isSubagentDetails(details) ? details : undefined;
117
+ }
118
+ if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
119
+ const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
120
+ if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait")) return undefined;
121
+ return isSubagentDetails(message.details) ? message.details : undefined;
122
+ }
123
+
124
+ function metadataUsage(
125
+ artifactsDirs: string[],
126
+ input: { runId: string; agent: string },
127
+ ): Usage | undefined {
128
+ if (!/^[A-Za-z0-9._-]+$/.test(input.runId)) return undefined;
129
+ for (const artifactsDir of artifactsDirs) {
130
+ for (const index of [0, undefined] as const) {
131
+ const metadataPath = getArtifactPaths(artifactsDir, input.runId, input.agent, index).metadataPath;
132
+ try {
133
+ const metadata = readUsageMetadata(metadataPath);
134
+ if (!metadata) continue;
135
+ if (metadata.runId !== input.runId || metadata.agent !== input.agent) continue;
136
+ const usage = usageFromValue(metadata.usage);
137
+ if (usage && usageHasValue(usage)) return usage;
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to read subagent usage metadata '${metadataPath}':`, error);
140
+ }
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ /**
147
+ * Collect parent and child usage for the current session branch. Foreground
148
+ * children come from persisted `subagent`/`bg_wait` tool-result details; async
149
+ * workflow children are resolved through receipts and artifact metadata.
150
+ */
151
+ export function collectSubagentCost(
152
+ ctx: ExtensionContext,
153
+ state: Pick<SubagentState, "baseCwd" | "artifactDirPreference">,
154
+ ): SubagentCostReport {
155
+ const parent = emptyUsage();
156
+ const childTotal = emptyUsage();
157
+ const total = emptyUsage();
158
+ const children: SubagentCostChild[] = [];
159
+ const seenChildren = new Set<string>();
160
+ const workflowRunIds = new Set<string>();
161
+ let unresolvedAsyncChildren = 0;
162
+
163
+ const addChild = (input: { agent?: string; runId?: string; usage?: Usage; sessionFile?: string }): boolean => {
164
+ if (!input.usage || !usageHasValue(input.usage)) return false;
165
+ const identity = input.runId ? `run:${input.runId}` : input.sessionFile ? `session:${input.sessionFile}` : undefined;
166
+ if (identity && seenChildren.has(identity)) return true;
167
+ if (identity) seenChildren.add(identity);
168
+ const usage = { ...input.usage };
169
+ children.push({
170
+ label: `Child ${children.length + 1} (${input.agent ?? "unknown"})`,
171
+ ...(input.agent ? { agent: input.agent } : {}),
172
+ ...(input.runId ? { runId: input.runId } : {}),
173
+ usage,
174
+ ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
175
+ });
176
+ addUsage(childTotal, usage);
177
+ return true;
178
+ };
179
+
180
+ for (const entry of ctx.sessionManager.getBranch()) {
181
+ const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
182
+ const parentUsage = assistantUsageFromMessage(message) ?? compactionUsageFromEntry(entry);
183
+ if (parentUsage) addUsage(parent, parentUsage);
184
+ const details = detailsFromSessionEntry(entry);
185
+ if (!details) continue;
186
+ if (details.mode === "workflow" && details.runId) workflowRunIds.add(details.runId);
187
+ for (const result of details.results) {
188
+ const resultRunId = (result as SingleResult & { runId?: unknown }).runId;
189
+ addChild({ agent: result.agent, runId: typeof resultRunId === "string" ? resultRunId : undefined, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
190
+ }
191
+ for (const completion of details.completions ?? []) {
192
+ if (completion.mode === "workflow") workflowRunIds.add(completion.runId);
193
+ for (const result of completion.results ?? []) {
194
+ addChild({ agent: result.agent, runId: result.runId, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
195
+ }
196
+ }
197
+ }
198
+
199
+ let sessionFile: string | null = null;
200
+ try {
201
+ sessionFile = ctx.sessionManager.getSessionFile() ?? null;
202
+ } catch { /* an unpersisted session can still report direct child usage */ }
203
+ const artifactsDirs = new Set<string>();
204
+ const addArtifactsDir = (cwd: string | undefined): void => {
205
+ try {
206
+ artifactsDirs.add(getArtifactsDir(sessionFile, cwd, state.artifactDirPreference));
207
+ } catch { /* invalid or unavailable project roots are ignored */ }
208
+ };
209
+ addArtifactsDir(ctx.cwd);
210
+ addArtifactsDir(state.baseCwd);
211
+
212
+ for (const workflowRunId of workflowRunIds) {
213
+ try {
214
+ const status = readStatus(path.join(DIRS.async, workflowRunId));
215
+ addArtifactsDir(status?.cwd);
216
+ const receipt = readWorkflowReceipt(DIRS.async, workflowRunId);
217
+ const summaryChildren = new Map((receipt.workflowChildren?.children ?? []).map((child) => [child.childId, child]));
218
+ const refsByRunId = new Map<string, { runId: string; agent?: string }>();
219
+ const addRef = (runId: string | undefined, agent: string | undefined): void => {
220
+ if (!runId) return;
221
+ const existing = refsByRunId.get(runId);
222
+ if (!existing || (!existing.agent && agent)) refsByRunId.set(runId, { runId, ...(agent ? { agent } : {}) });
223
+ };
224
+ for (const [key, entry] of Object.entries(receipt.entries)) {
225
+ const summaryChild = summaryChildren.get(key);
226
+ const runIds = entry.continuation?.runIds?.length
227
+ ? entry.continuation.runIds
228
+ : entry.latestRunId ? [entry.latestRunId] : summaryChild?.runId ? [summaryChild.runId] : [];
229
+ for (const runId of runIds) addRef(runId, entry.agent ?? summaryChild?.agent);
230
+ }
231
+ for (const child of receipt.workflowChildren?.children ?? []) {
232
+ if (!Object.hasOwn(receipt.entries, child.childId)) addRef(child.runId, child.agent);
233
+ }
234
+ for (const ref of refsByRunId.values()) {
235
+ if (seenChildren.has(`run:${ref.runId}`)) continue;
236
+ if (!ref.agent) {
237
+ unresolvedAsyncChildren += 1;
238
+ continue;
239
+ }
240
+ const usage = metadataUsage([...artifactsDirs], { runId: ref.runId, agent: ref.agent });
241
+ if (!usage || !addChild({ ...ref, usage })) unresolvedAsyncChildren += 1;
242
+ }
243
+ } catch (error) {
244
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to resolve async subagent usage for '${workflowRunId}':`, error);
245
+ }
246
+ }
247
+
248
+ addUsage(total, parent);
249
+ addUsage(total, childTotal);
250
+ return { version: SUBAGENT_COST_REPORT_VERSION, parent, children, childTotal, total, unresolvedAsyncChildren };
251
+ }
252
+
253
+ function formatCostUsage(label: string, usage: Usage): string {
254
+ const extras = [
255
+ usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
256
+ usage.cacheWrite ? `cache write ${formatTokens(usage.cacheWrite)}` : "",
257
+ usage.turns ? `${usage.turns} turn${usage.turns === 1 ? "" : "s"}` : "",
258
+ ].filter(Boolean);
259
+ return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
260
+ }
261
+
262
+ /** The `/subagent-cost` text rendering of a collected report. */
263
+ export function formatSubagentCostReport(report: SubagentCostReport): string {
264
+ const lines = [
265
+ "Subagent cost",
266
+ "",
267
+ formatCostUsage("Parent", report.parent),
268
+ ];
269
+ if (report.children.length === 0) {
270
+ lines.push("No subagent child usage found in this session.");
271
+ } else {
272
+ for (const child of report.children) {
273
+ lines.push(formatCostUsage(child.label, child.usage));
274
+ if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
275
+ }
276
+ }
277
+ if (report.unresolvedAsyncChildren > 0) lines.push(`Async child usage unavailable: ${report.unresolvedAsyncChildren}.`);
278
+ lines.push("────────────────────────────", formatCostUsage("Children", report.childTotal), formatCostUsage("Total", report.total));
279
+ return lines.join("\n");
280
+ }
@@ -17,6 +17,7 @@ import { createEventBus, createTempDir, events, makeAgent, makeMinimalCtx, remov
17
17
  import { discoverAgents } from "../../src/agents/agents.ts";
18
18
  import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
19
19
  import { deriveForkPromptCacheKey } from "../../src/runs/shared/child-tool-plan.ts";
20
+ import { INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR, validateStructuredOutputValue } from "../../src/runs/shared/structured-output.ts";
20
21
  import type { AsyncExecutionResult, AsyncResultPayload, AsyncStatusPayload } from "../support/async-execution-fixture.ts";
21
22
  import {
22
23
  installAsyncExecutionHooks, waitForMockPiRuntime, available, isAsyncAvailable,
@@ -1102,6 +1103,72 @@ export default function() {
1102
1103
  assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
1103
1104
  });
1104
1105
 
1106
+ it("background settlement preserves rejected structured_output evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1107
+ const diagnostic = "Structured output validation failed: ok: is required";
1108
+ mockPi.onCall({
1109
+ jsonl: [
1110
+ { type: "message_end", message: { role: "assistant", content: [{ type: "toolCall", id: "structured-rejected", name: "structured_output", arguments: { value: {} } }], model: "mock/test-model", stopReason: "toolUse", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } } } },
1111
+ { type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: {} } },
1112
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: diagnostic }] } },
1113
+ { type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output" },
1114
+ ],
1115
+ });
1116
+ const id = `async-single-schema-rejected-${Date.now().toString(36)}`;
1117
+
1118
+ executeAsyncSingle(id, {
1119
+ agent: "worker",
1120
+ task: "Return structured data",
1121
+ agentConfig: makeAgent("worker"),
1122
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1123
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1124
+ shareEnabled: false,
1125
+ sessionRoot: path.join(tempDir, "sessions"),
1126
+ maxSubagentDepth: 2,
1127
+ acceptance: false,
1128
+ structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
1129
+ });
1130
+
1131
+ const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
1132
+ const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
1133
+ assert.equal(payload.success, false);
1134
+ assert.match(payload.results[0]?.error ?? "", /Structured output validation failed: ok: is required/);
1135
+ assert.doesNotMatch(payload.results[0]?.error ?? "", /Missing structured_output call/);
1136
+ assert.equal(payload.results[0]?.structuredOutputFailed, true);
1137
+ assert.match(status.steps?.[0]?.error ?? "", /Structured output validation failed: ok: is required/);
1138
+ });
1139
+
1140
+ it("does not persist malformed outputSchema compiler text in background evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1141
+ const sentinel = "BACKGROUND_PRIVATE_SCHEMA_SENTINEL";
1142
+ const structuredOutputSchema = { type: "string", pattern: `${sentinel}_[invalid` };
1143
+ const validation = await validateStructuredOutputValue(structuredOutputSchema, "value");
1144
+ assert.equal(validation.status, "invalid");
1145
+ if (validation.status !== "invalid") return;
1146
+ assert.match(validation.message, new RegExp(sentinel));
1147
+ mockPi.onCall({
1148
+ jsonl: [
1149
+ { type: "tool_execution_start", toolCallId: "structured-malformed-schema", toolName: "structured_output", args: { value: "value" } },
1150
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-malformed-schema", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: ${validation.message}` }] } },
1151
+ { type: "tool_execution_end", toolCallId: "structured-malformed-schema", toolName: "structured_output" },
1152
+ ],
1153
+ });
1154
+ const id = `async-single-malformed-schema-${Date.now().toString(36)}`;
1155
+ executeAsyncSingle(id, {
1156
+ agent: "worker", task: "Return structured data", agentConfig: makeAgent("worker"),
1157
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1158
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1159
+ shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2,
1160
+ acceptance: false, structuredOutputSchema,
1161
+ });
1162
+
1163
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1164
+ const payloadText = fs.readFileSync(resultPath, "utf-8");
1165
+ const payload = JSON.parse(payloadText) as AsyncResultPayload;
1166
+ const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
1167
+ assert.match(payload.results[0]?.error ?? "", new RegExp(`^${escapeRegExp(INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR)}`));
1168
+ assert.equal(payloadText.includes(sentinel), false);
1169
+ assert.equal((status.steps?.[0]?.error ?? "").includes(sentinel), false);
1170
+ });
1171
+
1105
1172
  it("background outputSchema runs fail closed when required acceptanceReport is missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1106
1173
  mockPi.onCall({ output: "structured", structuredOutput: { ok: true } });
1107
1174
  const id = `async-schema-missing-acceptance-${Date.now().toString(36)}`;