@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
@@ -75,13 +75,42 @@ export const DEFAULT_CONFIG_PATH = path.join(
75
75
  AGENT_ROOT,
76
76
  "hermes-memory-config.json",
77
77
  );
78
+ export const DEFAULT_SETTINGS_PATH = path.join(AGENT_ROOT, "settings.json");
79
+ export const HERMES_MEMORY_SETTINGS_KEY = "hermesMemory";
78
80
 
79
- export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
81
+ function readConfigObject(configPath: string | undefined): Record<string, unknown> | undefined {
82
+ if (!configPath) return undefined;
80
83
  try {
81
- if (fs.existsSync(configPath)) {
82
- const raw = fs.readFileSync(configPath, "utf-8");
83
- const parsed = JSON.parse(raw);
84
- // Merge: override defaults with user config
84
+ const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8"));
85
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
86
+ ? parsed as Record<string, unknown>
87
+ : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+
97
+ /** Read extension settings from settings.json, falling back to the legacy file. */
98
+ export function loadConfig(
99
+ configPath = DEFAULT_CONFIG_PATH,
100
+ settingsPath?: string,
101
+ ): MemoryConfig {
102
+ try {
103
+ const legacyConfig = readConfigObject(configPath);
104
+ const resolvedSettingsPath = settingsPath
105
+ ?? (configPath === DEFAULT_CONFIG_PATH ? DEFAULT_SETTINGS_PATH : undefined);
106
+ const settings = readConfigObject(resolvedSettingsPath);
107
+ const extensionSettings = settings?.[HERMES_MEMORY_SETTINGS_KEY];
108
+ if (legacyConfig || isRecord(extensionSettings)) {
109
+ const parsed: Record<string, unknown> = {
110
+ ...(legacyConfig ?? {}),
111
+ ...(isRecord(extensionSettings) ? extensionSettings : {}),
112
+ };
113
+ // New settings.json values take precedence over the legacy file.
85
114
  const config: MemoryConfig = { ...DEFAULT_CONFIG };
86
115
  const isNonNegativeNumber = (value: unknown): value is number => (
87
116
  typeof value === "number" && Number.isFinite(value) && value >= 0
@@ -156,8 +185,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
156
185
  if (normalizedProjectsMemoryDir) config.projectsMemoryDir = normalizedProjectsMemoryDir;
157
186
  }
158
187
  if (
159
- typeof parsed.sessionSearch === "object" &&
160
- parsed.sessionSearch !== null &&
188
+ isRecord(parsed.sessionSearch) &&
161
189
  isSessionSearchVariant(parsed.sessionSearch.variant)
162
190
  ) {
163
191
  config.sessionSearch = { variant: parsed.sessionSearch.variant };
@@ -204,7 +232,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
204
232
  return config;
205
233
  }
206
234
  } catch {
207
- // Fall back to defaults on parse error or access issues
235
+ // Fall back to defaults on unexpected parse or access issues.
208
236
  }
209
237
  return { ...DEFAULT_CONFIG };
210
238
  }
@@ -29,12 +29,12 @@ export const DEFAULT_NUDGE_TOOL_CALLS = 15;
29
29
  export const DEFAULT_REVIEW_RECENT_MESSAGES = 0;
30
30
  export const DEFAULT_FLUSH_RECENT_MESSAGES = 0;
31
31
  /**
32
- * A consolidation run pays child-process boot plus a full LLM turn, which
33
- * routinely exceeds 60s — at the old 60s default the auto path was killed
34
- * mid-run on every attempt (#136). Configured values are honored verbatim,
35
- * including lower ones; `loadConfig` warns when a value below this is set.
32
+ * A consolidation run pays child-process boot plus a full LLM turn. Five
33
+ * minutes leaves room for slower models and larger memory stores. Configured
34
+ * values are honored verbatim, including lower ones; `loadConfig` warns when a
35
+ * value below this is set.
36
36
  */
37
- export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 180000;
37
+ export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 300000;
38
38
  /** Wall-clock grace after overflow before an automatic consolidation may run. */
39
39
  export const DEFAULT_OVERFLOW_GRACE_MS = 180000;
40
40
  export const DEFAULT_FAILURE_INJECTION_MAX_AGE_DAYS = 7;
@@ -337,64 +337,78 @@ export function registerConsolidateCommand(
337
337
  });
338
338
  }
339
339
 
340
- try {
341
- ctx.ui.notify(
342
- `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
343
- "info",
344
- );
345
- } catch {
346
- // Best-effort only. If the command context is already stale, continue
347
- // with the consolidation work rather than failing before it starts.
348
- }
349
-
350
- for (const item of targets) {
351
- const entries = entriesForTarget(item.store, item.target);
352
-
353
- if (entries.length === 0) {
354
- results.push(`${item.label}: (empty, nothing to consolidate)`);
355
- continue;
340
+ const setStatus = (text?: string) => {
341
+ try {
342
+ ctx.ui.setStatus("pi-hermes-memory:consolidation", text);
343
+ } catch {
344
+ // Best-effort progress feedback only.
356
345
  }
346
+ };
357
347
 
348
+ setStatus("Preparing memory consolidation…");
349
+ try {
358
350
  try {
359
351
  ctx.ui.notify(
360
- `⏳ Consolidating ${item.label}...`,
352
+ `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
361
353
  "info",
362
354
  );
363
355
  } catch {
364
- // Best-effort progress feedback only.
356
+ // Best-effort only. If the command context is already stale, continue
357
+ // with the consolidation work rather than failing before it starts.
365
358
  }
366
359
 
367
- const result = await triggerConsolidation(
368
- pi,
369
- item.store,
370
- item.target,
371
- ctx.signal,
372
- timeoutMs,
373
- item.toolTarget,
374
- llmConfig,
375
- ctx,
376
- dbManager,
377
- activeProjectName,
378
- deps,
379
- );
380
-
381
- if (result.consolidated) {
382
- await item.store.loadFromDisk();
383
- results.push(`${item.label}: ✅ consolidated`);
384
- } else {
385
- results.push(`${item.label}: ❌ ${result.error}`);
360
+ for (const [index, item] of targets.entries()) {
361
+ const entries = entriesForTarget(item.store, item.target);
362
+
363
+ if (entries.length === 0) {
364
+ results.push(`${item.label}: (empty, nothing to consolidate)`);
365
+ continue;
366
+ }
367
+
368
+ setStatus(`Consolidating ${item.label} (${index + 1}/${targets.length})…`);
369
+ try {
370
+ ctx.ui.notify(
371
+ `⏳ Consolidating ${item.label}...`,
372
+ "info",
373
+ );
374
+ } catch {
375
+ // Best-effort progress feedback only.
376
+ }
377
+
378
+ const result = await triggerConsolidation(
379
+ pi,
380
+ item.store,
381
+ item.target,
382
+ ctx.signal,
383
+ timeoutMs,
384
+ item.toolTarget,
385
+ llmConfig,
386
+ ctx,
387
+ dbManager,
388
+ activeProjectName,
389
+ deps,
390
+ );
391
+
392
+ if (result.consolidated) {
393
+ await item.store.loadFromDisk();
394
+ results.push(`${item.label}: ✅ consolidated`);
395
+ } else {
396
+ results.push(`${item.label}: ❌ ${result.error}`);
397
+ }
386
398
  }
387
- }
388
399
 
389
- const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
400
+ const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
390
401
 
391
- try {
392
- ctx.ui.notify(summary, "info");
393
- } catch {
394
- // Child consolidation can indirectly trigger a runtime reload/session
395
- // replacement. If that happens, the original command ctx is stale by
396
- // the time we reach the final summary, so the command should exit
397
- // quietly instead of surfacing a stale-ctx error.
402
+ try {
403
+ ctx.ui.notify(summary, "info");
404
+ } catch {
405
+ // Child consolidation can indirectly trigger a runtime reload/session
406
+ // replacement. If that happens, the original command ctx is stale by
407
+ // the time we reach the final summary, so the command should exit
408
+ // quietly instead of surfacing a stale-ctx error.
409
+ }
410
+ } finally {
411
+ setStatus(undefined);
398
412
  }
399
413
  },
400
414
  });
@@ -7,9 +7,11 @@ import { loadConfig } from "../src/config.js";
7
7
  import { AGENT_ROOT } from "../src/paths.js";
8
8
 
9
9
  const TEST_CONFIG_PATH = path.join(os.tmpdir(), `hermes-memory-config-test-${process.pid}.json`);
10
+ const TEST_SETTINGS_PATH = path.join(os.tmpdir(), `hermes-settings-test-${process.pid}.json`);
10
11
 
11
12
  afterEach(() => {
12
13
  fs.rmSync(TEST_CONFIG_PATH, { force: true });
14
+ fs.rmSync(TEST_SETTINGS_PATH, { force: true });
13
15
  });
14
16
 
15
17
  describe("loadConfig", () => {
@@ -30,7 +32,7 @@ describe("loadConfig", () => {
30
32
  assert.strictEqual(config.flushRecentMessages, 0);
31
33
  assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
32
34
  assert.strictEqual(config.autoConsolidate, true);
33
- assert.strictEqual(config.consolidationTimeoutMs, 180000);
35
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
34
36
  assert.strictEqual(config.overflowGraceMs, 180000);
35
37
  assert.strictEqual(config.autoConsolidationWarnOnFailure, true);
36
38
  assert.strictEqual(config.failureInjectionEnabled, true);
@@ -65,7 +67,7 @@ describe("loadConfig", () => {
65
67
  "a lower configured value must be honored, not clamped",
66
68
  );
67
69
  assert.strictEqual(warnings.length, 1, "a sub-default value should warn once");
68
- assert.match(warnings[0], /60000ms.*below the 180000ms default/);
70
+ assert.match(warnings[0], /60000ms.*below the 300000ms default/);
69
71
  } finally {
70
72
  console.warn = originalWarn;
71
73
  }
@@ -112,6 +114,28 @@ describe("loadConfig", () => {
112
114
  assert.strictEqual(config.reviewEnabled, true);
113
115
  });
114
116
 
117
+ it("loads hermesMemory from settings.json with precedence over the legacy config", () => {
118
+ fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
119
+ fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
120
+ llmThinkingOverride: "high",
121
+ consolidationTimeoutMs: 240000,
122
+ }));
123
+ fs.writeFileSync(TEST_SETTINGS_PATH, JSON.stringify({
124
+ theme: "dark",
125
+ hermesMemory: {
126
+ llmThinkingOverride: "off",
127
+ llmModelOverride: " tokenin/deepseek-v4.1-flash ",
128
+ consolidationTimeoutMs: 300000,
129
+ },
130
+ }));
131
+
132
+ const config = loadConfig(TEST_CONFIG_PATH, TEST_SETTINGS_PATH);
133
+ assert.strictEqual(config.llmThinkingOverride, "off");
134
+ assert.strictEqual(config.llmModelOverride, "tokenin/deepseek-v4.1-flash");
135
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
136
+ assert.strictEqual(config.memoryMode, "policy-only");
137
+ });
138
+
115
139
  it("only accepts boolean quickCheckOnOpen overrides", () => {
116
140
  fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
117
141
  fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ quickCheckOnOpen: "false" }));
@@ -631,6 +631,7 @@ describe("registerConsolidateCommand", () => {
631
631
  it("includes project memory when a project store is available", async () => {
632
632
  let handler: any;
633
633
  const notifications: string[] = [];
634
+ const statuses: Array<[string, string | undefined]> = [];
634
635
  let projectReloaded = false;
635
636
 
636
637
  const pi = {
@@ -655,7 +656,10 @@ describe("registerConsolidateCommand", () => {
655
656
  registerConsolidateCommand(pi, mockStore, 60000, projectStore, "demo-project");
656
657
  await handler({}, {
657
658
  signal: undefined,
658
- ui: { notify: (message: string) => { notifications.push(message); } },
659
+ ui: {
660
+ notify: (message: string) => { notifications.push(message); },
661
+ setStatus: (key: string, text: string | undefined) => { statuses.push([key, text]); },
662
+ },
659
663
  });
660
664
 
661
665
  assert.strictEqual(execCalls.length, 4, "should consolidate memory, user, failure, and project stores");
@@ -670,6 +674,8 @@ describe("registerConsolidateCommand", () => {
670
674
  assert.ok(projectReloaded, "project store should reload after consolidation");
671
675
  assert.ok(notifications.some((message) => message.includes("Starting memory consolidation")), "should show an initial progress notification");
672
676
  assert.ok(notifications.some((message) => message.includes("⏳ Consolidating memory")), "should show per-target progress");
677
+ assert.ok(statuses.some(([key, text]) => key === "pi-hermes-memory:consolidation" && text?.includes("Consolidating memory")), "should publish busy progress");
678
+ assert.deepStrictEqual(statuses.at(-1), ["pi-hermes-memory:consolidation", undefined], "should clear busy progress when done");
673
679
  const finalNotification = notifications[notifications.length - 1] ?? "";
674
680
  assert.ok(finalNotification.includes("failure: ✅ consolidated"), "final notification should include failure result");
675
681
  assert.ok(finalNotification.includes("project:demo-project: ✅ consolidated"), "final notification should include project result");
@@ -1186,7 +1186,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1186
1186
  const deliveredEntry = { ...entry, message: injectedMessage, replyCommand };
1187
1187
  replyTracker.queueTurnContext({ from: entry.from, message: injectedMessage, receivedAt: Date.now() });
1188
1188
  const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
1189
- const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool: ${replyCommand}` : "";
1189
+ // The tool may be an inactive optional capability: say how to activate it, not just its call.
1190
+ const activateHint = pi.getActiveTools().includes("intercom")
1191
+ ? ""
1192
+ : ' (not active yet: call capability_discover with name "intercom" first)';
1193
+ const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool${activateHint}: ${replyCommand}` : "";
1190
1194
  const deliveryMetadata = formatInboundDeliveryMetadata(injectedMessage);
1191
1195
  pi.sendMessage(
1192
1196
  {
@@ -2,12 +2,13 @@
2
2
  name: worker
3
3
  description: Implementation agent for normal tasks and approved oracle handoffs
4
4
  aliases: developer, coder, implementer, develop
5
+ acceptanceRole: writer
5
6
  thinking: high
6
7
  systemPromptMode: replace
7
8
  inheritProjectContext: true
8
9
  inheritSkills: false
9
10
  tools: read, grep, find, ls, bash, edit, write, contact_supervisor
10
- defaultContext: fork
11
+ defaultContext: fresh
11
12
  output: implementation.md
12
13
  defaultReads: context.md, research.md, plan.md, implementation.md, review.md
13
14
  defaultProgress: true
@@ -17,7 +18,7 @@ You are `worker`: the implementation subagent.
17
18
 
18
19
  You are the single writer thread. Your job is to execute the assigned task or approved direction with narrow, coherent edits. The main agent and user remain the decision authority.
19
20
 
20
- Use the provided tools directly. First read the inherited context, supplied files, plan, task paths, and named seams. Then implement carefully and minimally. Use broad search only to verify or expand from that starting point.
21
+ Use the provided tools directly. First read the provided context, supplied files, plan, task paths, and named seams. Then implement carefully and minimally. Use broad search only to verify or expand from that starting point.
21
22
 
22
23
  The builtin worker uses a strict tool allowlist. It does not inherit ambient extension tools from the parent session. To use an extension tool, configure a custom agent with the tool name explicitly listed in `tools` and load its provider through `extensions` or `subagentOnlyExtensions`.
23
24
 
@@ -198,6 +198,8 @@ fallbackModels:
198
198
 
199
199
  Field notes:
200
200
 
201
+ Native children expose the capability gateway only when extension policy permits it. The gateway catalogs and activates only tools visible in that child's effective registry; it does not install or provide a missing tool provider.
202
+
201
203
  | Field | Notes |
202
204
  |-------|-------|
203
205
  | `package` | Optional package identifier. A file with `name: scout` and `package: code-analysis` registers as `code-analysis.scout`; serialization keeps `name` and `package` separate. |
@@ -114,7 +114,7 @@ pi.events.emit("subagents:rpc:v1:request", {
114
114
  });
115
115
  ```
116
116
 
117
- The RPC methods are `ping`, `status`, `manage`, `spawn`, `steer`, `interrupt`, `stop`, and `resume`. `status`, `manage`, `steer`, `interrupt`, and `resume` reuse normal package-owned actions.
117
+ The RPC methods are `ping`, `status`, `manage`, `spawn`, `steer`, `interrupt`, `stop`, `resume`, and `cost`. `status`, `manage`, `steer`, `interrupt`, and `resume` reuse normal package-owned actions.
118
118
 
119
119
  Method notes:
120
120
 
@@ -124,6 +124,7 @@ Method notes:
124
124
  - `resume` requires a run target and non-empty `message`. It delegates to the existing revival path, which validates current-session ownership, persisted session/recovery metadata, stopped/live state, capability ceilings, and the exclusive session lease before returning the new async run details. Callers may request a `file-only` output path for the revived result without overriding its model, tools, or budgets. `ping.capabilities.resume` advertises this seam.
125
125
  - `stop` targets current-session top-level async runs through the stop control channel and records a `stopped` lifecycle instead of reporting a timeout.
126
126
  - `status` keeps targeted and rich requests on the executor-backed path. A request with no `id`, `runId`, `dir`, `index`, `view`, or `lines` may use the restored in-memory projections and a short summary; when the live state is missing, stale, session-mismatched, or not restored, it falls back to normal executor status. Status `view`, `lines`, and `index` are forwarded for targeted transcript/fleet requests. Successful replies retain `text`, `details`, `fleet`, and `asyncSnapshot`; the short summary intentionally omits canonical filesystem details, wait subscriptions, and budget annotations.
127
+ - `cost` returns the same parent-plus-child accounting `/subagent-cost` renders, as `{ version: 1, parent, children, childTotal, total, unresolvedAsyncChildren }`. Usage objects contain `input`, `output`, `cacheRead`, `cacheWrite`, `cost`, and `turns`; child rows add `label` plus `agent`, `runId`, or `sessionFile` when known. It is read-only and walks the current session branch and existing artifacts, so request it at a turn boundary rather than on a timer. A non-zero `unresolvedAsyncChildren` means `childTotal` is a lower bound. `ping.capabilities.cost` advertises `{ version: 1 }`.
127
128
 
128
129
  Capability advertisements on `ping`:
129
130
 
@@ -136,6 +137,7 @@ Capability advertisements on `ping`:
136
137
  - `resume` — the revival seam described above.
137
138
  - `statusProjection: { version: 1, untargeted: "in-memory-when-ready", targeted: "executor" }` — untargeted status may use restored bounded projections; targeted or rich status remains executor-backed.
138
139
  - `fleetStatus: { version: 1 }` — successful `status` replies additionally include `data.fleet`.
140
+ - `cost: { version: 1 }` — the `cost` method is available with the report shape described above.
139
141
 
140
142
  Structured delegation progress updates carry `runId` as soon as foreground execution allocates it, so a caller can retain the package-owned revival target even if its own tool turn is interrupted before the terminal response. Foreground `details.results[]` rows also include a numeric `index` that is unique within the run and stable across partial progress snapshots and the final result; use `(runId, index)` instead of row position to correlate single, counted parallel, and chain children.
141
143
 
@@ -27,6 +27,8 @@ subagent({ action: "status", id: "..." }) // one run
27
27
 
28
28
  Or ask naturally: "Show me the current async runs."
29
29
 
30
+ Use `/subagent-cost` for combined parent-plus-child usage. Other extensions can request the same versioned data through the in-process RPC `cost` method instead of scraping slash-command text; it is read-only and should be called at a turn boundary, not polled. `unresolvedAsyncChildren` counts children whose usage metadata could not be read, so a non-zero count means the child total is a lower bound. See [extension-api.md](extension-api.md#in-process-event-bus-rpc).
31
+
30
32
  The under-editor async widget gives a short view while work runs. Its expand key follows your Pi keybinding:
31
33
 
32
34
  ```text
@@ -90,7 +90,7 @@ The complete plain-JSON inventory is validated before the first launch (maximum
90
90
  | `action` | string | - | Offline workflow `validate`, agent management (including `guide`, `children.list`, and `refine`/`refine.show`/`refine.rollback`), lane evidence (`lane.status`, `lane.recordMerge`, `lane.recordSupersession`), mission (`mission.create/list/show/update/resolve-decision/attach-run/close`), Herdr inspector (`inspector.open/status/close`), Herdr project pane (`project.open/status/close`), status/control, plan-only `worktree.cleanup`, schedule, watchdog, or doctor action. |
91
91
  | `topic` | `overview \| workflows \| agents \| missions \| observability \| tool-reference \| configuration \| models \| watchdog \| extension-api` | `overview` | Packaged guide topic for `action: "guide"`. |
92
92
  | `config` | object/string | - | Agent config for management create/update. |
93
- | `context` | `fresh \| fork` | global or per-agent default, else `fresh` | Explicit `fresh` or `fork` overrides every workflow child. When omitted, [`defaultSubagentContext`](configuration.md#defaultsubagentcontext) wins over each agent's `defaultContext`; `"fork"` creates a real branched session when the parent session file and current leaf exist, otherwise it falls back to `fresh`. Packaged `worker`, `oracle`, and `advisor` default to `fork`. |
93
+ | `context` | `fresh \| fork` | global or per-agent default, else `fresh` | Explicit `fresh` or `fork` overrides every workflow child. When omitted, [`defaultSubagentContext`](configuration.md#defaultsubagentcontext) wins over each agent's `defaultContext`; `"fork"` creates a real branched session when the parent session file and current leaf exist, otherwise it falls back to `fresh`. Packaged `worker` defaults to `fresh`; `oracle` and `advisor` default to `fork`. |
94
94
  | `missionId` | string | - | Attach a workflow to an existing project mission instead of creating its default enclosing mission. |
95
95
  | `mission` | object/false | auto-create | Override the default enclosing mission with `{ title \| summary, objective?, goal?, budget?, labels? }`. Set exactly one non-empty `title` or `summary`; `objective` and `labels` are optional. `goal` may only be `true`, requires `budget.tokens`, and enables continuation notices. Pass `false` for an intentionally ephemeral workflow with no mission for it or its children and no `state` global. Explicit mission persistence failures are strict. |
96
96
  | `handoffPath` | string | - | Aggregate handoff manifest for `action: "worktree.discard"` or lane evidence actions, or optional explicit metadata for `action: "worktree.cleanup"`. |
@@ -134,7 +134,7 @@ Explicit `context: "fork"` fails fast when the parent session is not persisted,
134
134
 
135
135
  When the inherited transcript contains signed Anthropic `thinking` / `redacted_thinking` blocks, `pi-subagents` strips those provider-private blocks from the forked child session. It forces thinking `off` only when the child's effective primary or fallback model resolves through the model registry to the Anthropic provider or `anthropic-messages` API; unresolved models are treated conservatively. The result reports every affected child, including on failed runs. Use `context: "fresh"` when an Anthropic child needs thinking. Explicit `context: "fork"` never silently downgrades to `fresh`.
136
136
 
137
- In workflow runs that omit `context`, each `runs.run` child follows the global `defaultSubagentContext` when set, then its own `defaultContext`. Without the global setting, a fresh-default scout can run fresh beside a fork-default worker. If the parent session file or current leaf is not available yet, implicit fork-default children run fresh. Pass explicit `context: "fork"` or `context: "fresh"` when you intentionally want one context for every child.
137
+ In workflow runs that omit `context`, each `runs.run` child follows the global `defaultSubagentContext` when set, then its own `defaultContext`. Without the global setting, a fresh-default worker can run fresh beside a fork-default oracle. If the parent session file or current leaf is not available yet, implicit fork-default children run fresh. Pass explicit `context: "fork"` or `context: "fresh"` when you intentionally want one context for every child.
138
138
 
139
139
  ### Workflow steering
140
140
 
@@ -10,7 +10,7 @@ Use orchestration as parent-agent guidance, not as a runtime workflow mode. For
10
10
  clarify → scout → worker → fresh reviewers → worker
11
11
  ```
12
12
 
13
- Packaged `worker`, `oracle`, and `advisor` default to forked context when a launch omits `context`. If the parent has no persisted session file or current leaf yet, that implicit default falls back to `fresh`. Pass `context: "fresh"` when you intentionally want a fresh child run, or `context: "fork"` when fork must remain strict.
13
+ Packaged `worker` defaults to fresh context; `oracle` and `advisor` default to forked context when a launch omits `context`. An implicit fork preference falls back to `fresh` when the parent has no persisted session file or current leaf. Pass `context: "fork"` when you intentionally want a worker to reuse the parent thread, or when fork must remain strict.
14
14
 
15
15
  Child-safety boundaries are enforced at runtime:
16
16
 
@@ -23,6 +23,7 @@ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text
23
23
  import { readStatus } from "../shared/utils.ts";
24
24
  import { SubagentParams } from "./schemas.ts";
25
25
  import { normalizePublicSubagentExecution } from "./public-execution.ts";
26
+ import { collectSubagentCost, SUBAGENT_COST_REPORT_VERSION } from "../slash/subagent-cost.ts";
26
27
  import { ASYNC_STATUS_SNAPSHOT_KIND, ASYNC_STATUS_SNAPSHOT_VERSION, buildAsyncStatusSnapshotForState } from "../runs/background/async-status-snapshot.ts";
27
28
  import { isStoppableAsyncStatusStep, resolveAsyncStatusChild, stopStoppableAsyncStatusChildren, type ResolvedAsyncStatusChild } from "../runs/shared/child-identity.ts";
28
29
 
@@ -31,7 +32,7 @@ export const SUBAGENT_RPC_REQUEST_EVENT = "subagents:rpc:v1:request";
31
32
  export const SUBAGENT_RPC_READY_EVENT = "subagents:rpc:v1:ready";
32
33
  export const SUBAGENT_RPC_REPLY_EVENT_PREFIX = "subagents:rpc:v1:reply:";
33
34
 
34
- export const SUBAGENT_RPC_METHODS = ["ping", "status", "manage", "spawn", "steer", "interrupt", "stop", "resume"] as const;
35
+ export const SUBAGENT_RPC_METHODS = ["ping", "status", "manage", "spawn", "steer", "interrupt", "stop", "resume", "cost"] as const;
35
36
  export type SubagentRpcMethod = typeof SUBAGENT_RPC_METHODS[number];
36
37
 
37
38
  export interface SubagentRpcRequestEnvelope {
@@ -456,6 +457,7 @@ function pingData(ctx: ExtensionContext | null) {
456
457
  launchResolvedExtensions: { version: 1, source: "launch-resolved" },
457
458
  runtimeAcknowledgedExtensions: { version: 1, source: "child-runtime", event: "subagent:acknowledge-extension" },
458
459
  processTerminalProof: { version: 1, lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION },
460
+ cost: { version: SUBAGENT_COST_REPORT_VERSION },
459
461
  },
460
462
  events: {
461
463
  ready: SUBAGENT_RPC_READY_EVENT,
@@ -761,6 +763,13 @@ async function handleRequest(
761
763
  if (request.method === "resume") {
762
764
  return executeChecked(options, ctx, request.requestId, request.method, resumeParams(request.params));
763
765
  }
766
+ if (request.method === "cost") {
767
+ // The same parent-plus-child accounting `/subagent-cost` renders, as data.
768
+ // Read-only: it walks the current session branch and existing artifacts,
769
+ // so callers should request it on their own turn boundaries, not on a timer.
770
+ if (request.params !== undefined && !isRecord(request.params)) throw new SubagentRpcError("invalid_params", "RPC cost params must be an object when provided.");
771
+ return collectSubagentCost(ctx, options.state ?? { baseCwd: ctx.cwd });
772
+ }
764
773
  throw new SubagentRpcError("unsupported_method", `Unsupported subagent RPC method: ${String(request.method)}`);
765
774
  }
766
775
 
@@ -6,6 +6,7 @@ import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } fro
6
6
  import { readStatus } from "../../shared/utils.ts";
7
7
  import { checkPidLiveness, type PidLiveness } from "./stale-run-reconciler.ts";
8
8
  import { readProcessTerminal } from "./process-terminal.ts";
9
+ import { isTerminalAsyncState as terminalState, readWorkflowChildProcessEvidence } from "./workflow-terminal-proof.ts";
9
10
 
10
11
  export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
11
12
  export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
@@ -209,10 +210,6 @@ function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacit
209
210
  }
210
211
  }
211
212
 
212
- function terminalState(state: AsyncStatus["state"]): boolean {
213
- return state !== "queued" && state !== "running" && state !== "paused";
214
- }
215
-
216
213
  function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
217
214
  if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
218
215
  if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
@@ -269,23 +266,8 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwner, status: AsyncSt
269
266
  if (status.mode !== "workflow") return { state: "retained", reason: `status mode is ${status.mode}, not workflow` };
270
267
  if (!terminalState(status.state)) return { state: "retained", reason: `workflow is still ${status.state}` };
271
268
  if (liveWorkflowRunIds.has(owner.runId)) return { state: "retained", reason: "workflow controller is still live" };
272
- for (const step of status.steps ?? []) {
273
- const label = step.workflowKey ?? step.agent;
274
- if (typeof step.async !== "boolean") return { state: "retained", reason: `workflow child ${label} is missing async classification` };
275
- if (!step.async) continue;
276
- if (!step.runId) return { state: "retained", reason: `async workflow child ${label} is missing run id` };
277
- const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
278
- if (!fs.existsSync(childDir)) return { state: "retained", reason: `async workflow child ${label} directory is missing` };
279
- const childStatus = readStatus(childDir);
280
- if (!childStatus) return { state: "retained", reason: `async workflow child ${label} status is missing or unreadable` };
281
- if (!terminalState(childStatus.state)) return { state: "retained", reason: `async workflow child ${label} is still ${childStatus.state}` };
282
- if (!childStatus.processTerminal?.runnerProcessInstanceId) return { state: "retained", reason: `async workflow child ${label} has no runner process identity` };
283
- const proof = readProcessTerminal(childDir, {
284
- runId: step.runId,
285
- runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
286
- });
287
- if (proof?.state !== "observed" || proof.runId !== step.runId) return { state: "retained", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
288
- }
269
+ const evidence = readWorkflowChildProcessEvidence(owner.asyncDir, status.steps);
270
+ if (evidence.state !== "observed") return { state: "retained", reason: evidence.reason };
289
271
  return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
290
272
  }
291
273
 
@@ -83,6 +83,7 @@ import { assertAgentAllowedByCapabilityCeiling, intersectSubagentCapabilityCeili
83
83
  import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
84
84
  import { resolvePermissionRules, type PermissionConfig } from "../shared/permissions.ts";
85
85
  import { normalizeExtensionBindings, omitExtensionBindingsEnv, type ExtensionBindings } from "../shared/extension-bindings.ts";
86
+ import { omitGitRoutingEnv } from "../shared/git-environment.ts";
86
87
  import { assertWorkflowLaneKey, normalizeWorkflowLaneMetadata } from "../shared/lane-metadata.ts";
87
88
 
88
89
  const require = createRequire(import.meta.url);
@@ -595,7 +596,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Om
595
596
  ...backgroundProcessOptions(),
596
597
  stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
597
598
  env: {
598
- ...omitExtensionBindingsEnv(process.env),
599
+ ...omitGitRoutingEnv(omitExtensionBindingsEnv(process.env)),
599
600
  [SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV]: piPackageRoot,
600
601
  [JITI_ALIAS_ENV]: JSON.stringify(hostPeerAliases.aliases),
601
602
  },
@@ -124,6 +124,7 @@ export interface RunChildSessionResult {
124
124
  observedMutationAttempt?: boolean;
125
125
  structuredOutputToolInvoked?: boolean;
126
126
  structuredOutputMessageStartIndex?: number;
127
+ structuredOutputFailed?: boolean;
127
128
  watchdog?: ChildWatchdogStateSnapshot;
128
129
  sessionFile?: string;
129
130
  currentTool?: string;
@@ -16,6 +16,7 @@ import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts
16
16
  import { normalizeExternalCliRunnerStatus } from "../shared/external-cli-contract.ts";
17
17
  import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
18
18
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
19
+ import { readWorkflowTerminalProof } from "./workflow-terminal-proof.ts";
19
20
  import { formatWaitSubscriptions } from "./wait-subscriptions.ts";
20
21
  import { resolveAsyncRunLocation } from "./async-resume.ts";
21
22
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
@@ -704,7 +705,10 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
704
705
 
705
706
  const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
706
707
  if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
707
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(status.workflowReceiptPath ? { workflowReceiptPath: status.workflowReceiptPath } : {}), ...(status.preflight ? { preflight: status.preflight } : {}), ...(status.workflow?.preflightWarnings?.length ? { preflightWarnings: status.workflow.preflightWarnings } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
708
+ const workflowTerminalProof = workflowChildren
709
+ ? readWorkflowTerminalProof(asyncDir, status.steps, workflowChildren, validHostStepNodes(status.workflowGraph).length, status.endedAt ?? status.lastUpdate ?? status.startedAt)
710
+ : undefined;
711
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(status.workflowReceiptPath ? { workflowReceiptPath: status.workflowReceiptPath } : {}), ...(status.preflight ? { preflight: status.preflight } : {}), ...(status.workflow?.preflightWarnings?.length ? { preflightWarnings: status.workflow.preflightWarnings } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowTerminalProof ? { workflowTerminalProof } : {}), ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
708
712
  }
709
713
  }
710
714