@selesai/code 0.13.31 → 0.13.33

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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/core/remote-catalog-provider.js +5 -1
  3. package/dist/core/remote-catalog-provider.test.d.ts +1 -0
  4. package/dist/core/remote-catalog-provider.test.js +29 -0
  5. package/dist/extensions/capability-gateway/index.ts +9 -4
  6. package/dist/extensions/capability-gateway/integration.test.ts +69 -3
  7. package/dist/extensions/pi-subagents/agents/worker.md +3 -2
  8. package/dist/extensions/pi-subagents/docs/agents.md +2 -0
  9. package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
  10. package/dist/extensions/pi-subagents/docs/observability.md +2 -0
  11. package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
  12. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  13. package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
  14. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
  15. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
  16. package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
  17. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  18. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
  19. package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
  20. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
  21. package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
  22. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
  23. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
  24. package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
  25. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
  26. package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
  27. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
  28. package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
  29. package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
  30. package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
  31. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
  32. package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
  33. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
  34. package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
  35. package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
  36. package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
  37. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
  38. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
  39. package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
  40. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
  41. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
  42. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
  43. package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
  44. package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
  45. package/dist/extensions/pi-web-agent/src/extension.ts +2 -1
  46. package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
  47. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  48. package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
  49. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to `@selesai/code` will be documented in this file.
4
4
 
5
+ ## [0.13.33] - 2026-09-26
6
+
7
+ ### Added
8
+ - **Subagent cost is exposed through extension RPC.** `cost` joins the versioned `subagents:rpc:v1` method list and returns the same parent-plus-child accounting `/subagent-cost` renders; `ping` advertises the report version. It is read-only, but it walks the current session branch and existing run artifacts, so callers should request it on turn boundaries rather than on a timer.
9
+ - **Async workflow status reports terminal proof.** `subagent({ action: "status" })` details now include `workflowTerminalProof`, the shared child-exit evidence for workflow runs.
10
+ - **Native children can use the scoped capability gateway.** A child launch loads the gateway when extension policy permits it, exposing `capability_catalog`, `capability_discover`, and `capability_skill_show` for tools in that child's effective registry. The gateway never installs or provides a missing tool provider, and `SELESAI_CAPABILITY_GATEWAY=0` still disables it.
11
+
12
+ ### Changed
13
+ - **The packaged `worker` agent starts from fresh context.** `worker` now declares `defaultContext: fresh` and `acceptanceRole: writer`; an explicit `context: "fork"` still wins.
14
+
15
+ ### Fixed
16
+ - **Capability gateway controls no longer count as mutation capability.** A read-only child that receives `capability_catalog`, `capability_discover`, and `capability_skill_show` is still rejected for an implementation task with no mutation-capable tools, so the gateway does not disarm the implementation guard.
17
+ - **Structured output rejections keep their evidence.** Foreground and background runs now report the bounded rejection diagnostic — schema and validator failures summarized, submitted values redacted — instead of a generic missing-call error, and background results mark `structuredOutputFailed`.
18
+ - **`subagent_supervisor` requires fanout authorization.** A child that declares the supervisor reply tool without `subagent` in its effective tools allowlist (or `allowNestedSubagents`) now fails the launch instead of silently receiving the tool.
19
+ - **Git routing environment is stripped from child processes.** Background runners and external CLI default launches no longer inherit `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and Git's other local environment variables, so a child's Git commands stay in its own working directory.
20
+
21
+ ## [0.13.32] - 2026-09-24
22
+
23
+ ### Fixed
24
+ - **Cached provider model catalogs are no longer discarded when `Last-Modified` is missing.** Models such as `openai-codex/gpt-6-sol` and `openai-codex/gpt-6-luna` remain available after a catalog refresh when the cached catalog is newer than the bundled metadata.
25
+
5
26
  ## [0.13.31] - 2026-09-23
6
27
 
7
28
  ### Changed
@@ -30,7 +30,11 @@ function parseCatalog(providerId, value) {
30
30
  function remoteModels(entry, localGeneratedAt) {
31
31
  if (!entry)
32
32
  return [];
33
- if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) {
33
+ // Some catalog responses (and the 404/501 fallback) have no usable
34
+ // Last-Modified value. Use the completed check time instead so a cached
35
+ // catalog is not discarded merely because that header was absent.
36
+ const remoteGeneratedAt = entry.lastModified && entry.lastModified > 0 ? entry.lastModified : entry.checkedAt;
37
+ if (localGeneratedAt !== undefined && (remoteGeneratedAt === undefined || remoteGeneratedAt <= localGeneratedAt)) {
34
38
  return [];
35
39
  }
36
40
  return entry.models;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { withRemoteCatalog } from "./remote-catalog-provider.js";
3
+ const cachedModel = {
4
+ id: "gpt-6-sol",
5
+ provider: "openai-codex",
6
+ };
7
+ function refreshContext(stored) {
8
+ return {
9
+ stored,
10
+ allowNetwork: false,
11
+ signal: new AbortController().signal,
12
+ publish: async ({ update }) => {
13
+ update?.();
14
+ return true;
15
+ },
16
+ };
17
+ }
18
+ describe("remote model catalog cache", () => {
19
+ it("keeps a newer cached catalog when Last-Modified is unavailable", async () => {
20
+ const localGeneratedAt = 100;
21
+ const provider = {
22
+ id: "openai-codex",
23
+ getModels: () => [],
24
+ };
25
+ const wrapped = withRemoteCatalog(provider, "https://example.test", localGeneratedAt);
26
+ await wrapped.refreshModels?.(refreshContext({ models: [cachedModel], checkedAt: localGeneratedAt + 1, lastModified: 0 }));
27
+ expect(wrapped.getModels().map((model) => model.id)).toEqual(["gpt-6-sol"]);
28
+ });
29
+ });
@@ -94,14 +94,19 @@ function catalogEntries(pi: ExtensionAPI): CatalogEntry[] {
94
94
  }
95
95
 
96
96
  const EMBEDDED_SKILL_BLOCK = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?<\/skill>/gi;
97
+ const GITHUB_OR_OPEN_SOURCE_QUERY = /\b(?:github|open[\s-]*source)\b/i;
97
98
 
98
99
  function routePrompt(prompt: string, entries: CatalogEntry[]): ReturnType<typeof route> {
99
100
  const loadedSkills = new Set([...prompt.matchAll(EMBEDDED_SKILL_BLOCK)].map((match) => match[1]!.toLowerCase()));
100
101
  const query = prompt.replace(EMBEDDED_SKILL_BLOCK, " ");
101
- return route(
102
- query,
103
- entries.filter((entry) => entry.kind !== "skill" || !loadedSkills.has(entry.name.toLowerCase())),
104
- );
102
+ const candidates = entries.filter((entry) => entry.kind !== "skill" || !loadedSkills.has(entry.name.toLowerCase()));
103
+ if (GITHUB_OR_OPEN_SOURCE_QUERY.test(query)) {
104
+ const grepAppSearch = candidates.find(
105
+ (entry) => entry.kind === "tool" && entry.eligible && entry.name === "grep_app_search",
106
+ );
107
+ if (grepAppSearch) return { action: "activate", entry: grepAppSearch };
108
+ }
109
+ return route(query, candidates);
105
110
  }
106
111
 
107
112
  function formatCatalog(entries: CatalogEntry[]): string {
@@ -19,6 +19,7 @@ import { allowNetwork } from "../../../test/test-network-env.ts";
19
19
  const EXTENSIONS_DIR = fileURLToPath(new URL("../../", import.meta.url));
20
20
  const GATEWAY_DIR = fileURLToPath(new URL(".", import.meta.url));
21
21
  const GREP_APP_DIR = fileURLToPath(new URL("../grep-app", import.meta.url));
22
+ const WEB_AGENT_DIR = fileURLToPath(new URL("../pi-web-agent", import.meta.url));
22
23
  const GRAFT_DIR = fileURLToPath(new URL("../pi-graft", import.meta.url));
23
24
  const INLINE_SKILLS_FILE = fileURLToPath(new URL("../inline-skills.ts", import.meta.url));
24
25
 
@@ -37,6 +38,8 @@ interface HarnessOptions {
37
38
  jev?: Record<string, unknown>;
38
39
  /** Make the gateway's telemetry channel throw on every emit. */
39
40
  telemetryDown?: boolean;
41
+ /** SDK-level child tool allowlist. */
42
+ tools?: string[];
40
43
  }
41
44
 
42
45
  /** A bus that fails only on the gateway's own telemetry channel. */
@@ -129,6 +132,7 @@ async function createGatewaySession(options: HarnessOptions): Promise<Harness> {
129
132
  resourceLoader: loader,
130
133
  sessionManager: SessionManager.create(cwd, join(home, "sessions")),
131
134
  settingsManager,
135
+ ...(options.tools !== undefined ? { tools: options.tools } : {}),
132
136
  });
133
137
  const session = created.session;
134
138
  await session.bindExtensions({});
@@ -243,6 +247,39 @@ describe("capability gateway integration", () => {
243
247
  expect(draw({})).toContain("(all)");
244
248
  });
245
249
 
250
+ it("limits child discovery and activation to the SDK-filtered tool registry", async () => {
251
+ const h = await createGatewaySession({
252
+ enabled: true,
253
+ extensions: [GATEWAY_DIR, GREP_APP_DIR],
254
+ tools: ["capability_catalog", "capability_discover", "grep_app_search"],
255
+ });
256
+ harnesses.push(h);
257
+ const registeredNames = h.session.getAllTools().map((tool) => tool.name);
258
+ expect(registeredNames).toContain("grep_app_search");
259
+ expect(registeredNames).not.toContain("grep_app_fetch");
260
+
261
+ const catalog = h.session.getToolDefinition("capability_catalog");
262
+ const catalogResult = await catalog!.execute("call-catalog", { query: "grep_app" }, undefined, undefined, {} as never);
263
+ expect(String(catalogResult.content[0]!.text)).toContain("grep_app_search");
264
+ expect(String(catalogResult.content[0]!.text)).not.toContain("grep_app_fetch");
265
+
266
+ const discover = h.session.getToolDefinition("capability_discover");
267
+ const allowed = await discover!.execute("call-allowed", { name: "grep_app_search" }, undefined, undefined, {} as never);
268
+ expect(String(allowed.content[0]!.text)).toContain("Activated");
269
+ expect(h.session.getActiveToolNames()).toContain("grep_app_search");
270
+
271
+ const denied = await discover!.execute("call-denied", { name: "grep_app_fetch" }, undefined, undefined, {} as never);
272
+ expect(String(denied.content[0]!.text)).not.toContain("Activated");
273
+ expect(h.session.getActiveToolNames()).not.toContain("grep_app_fetch");
274
+ });
275
+
276
+ it("exposes no gateway controls to an explicitly empty child tool allowlist", async () => {
277
+ const h = await createGatewaySession({ enabled: true, extensions: [GATEWAY_DIR], tools: [] });
278
+ harnesses.push(h);
279
+ expect(h.session.getAllTools().map((tool) => tool.name)).not.toContain("capability_catalog");
280
+ expect(h.session.getActiveToolNames()).not.toContain("capability_discover");
281
+ });
282
+
246
283
  it("activates a discovered tool for the run and resets after agent_settled", async () => {
247
284
  const h = await createGatewaySession({ enabled: true });
248
285
  harnesses.push(h);
@@ -337,9 +374,9 @@ describe("capability gateway integration", () => {
337
374
  // ---------------------------------------------------------------------------
338
375
 
339
376
  describe("capability gateway Jev routing", () => {
340
- // "...github..." only weakly suggests both grep-app tools: the deterministic router returns a
341
- // two-candidate hint, which is the only Jev trigger.
342
- const HINT_PROMPT = "look at this github repo";
377
+ // "search fetch" weakly suggests both grep-app tools: the deterministic router returns a
378
+ // two-candidate hint, which is the only Jev trigger. GitHub/open-source mentions route directly.
379
+ const HINT_PROMPT = "search fetch";
343
380
  // No tool name, alias, or summary token matches: the router has no lexical signal and Jev
344
381
  // must never be consulted.
345
382
  const NO_SIGNAL_PROMPT = "continue where we left off last time";
@@ -393,6 +430,35 @@ describe("capability gateway Jev routing", () => {
393
430
  return h.telemetry.filter((event) => event.event === "route");
394
431
  }
395
432
 
433
+ it("routes only GitHub/open-source mentions to grep.app; other web searches stay on web_explore", async () => {
434
+ allowNetwork();
435
+ const { fetchMock } = stubJev("none", 1);
436
+ const h = await createGatewaySession({
437
+ enabled: true,
438
+ extensions: [GATEWAY_DIR, GREP_APP_DIR, WEB_AGENT_DIR],
439
+ jev: jevSettings(),
440
+ });
441
+ harnesses.push(h);
442
+
443
+ for (const prompt of [
444
+ "search GitHub code",
445
+ "find open-source implementations",
446
+ "find open source implementations",
447
+ "find opensource examples",
448
+ ]) {
449
+ await route(h, prompt);
450
+ expect(h.session.getActiveToolNames()).toContain("grep_app_search");
451
+ expect(h.session.getActiveToolNames()).not.toContain("web_explore");
452
+ expect(fetchMock).not.toHaveBeenCalled();
453
+ await h.session.extensionRunner.emit({ type: "agent_settled" });
454
+ }
455
+
456
+ await route(h, "web search for release notes");
457
+ expect(h.session.getActiveToolNames()).toContain("web_explore");
458
+ expect(h.session.getActiveToolNames()).not.toContain("grep_app_search");
459
+ expect(fetchMock).not.toHaveBeenCalled();
460
+ });
461
+
396
462
  // Telemetry may carry route metadata and canonical tool names, never prompt
397
463
  // text, conversation turns, credentials, raw Jev output, or tool arguments.
398
464
  const TELEMETRY_KEYS = new Set([
@@ -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
 
@@ -105,7 +105,7 @@ import { SUBAGENT_CHILD_ENV } from "../shared/child-runtime-config.ts";
105
105
  import { deriveChildSessionName } from "../../shared/child-session-name.ts";
106
106
  import { alignForkedSessionCwd } from "../../shared/fork-session-cwd.ts";
107
107
  import { outputEntryFromAsyncResult, resolveOutputReferences } from "../shared/chain-outputs.ts";
108
- import { clearStructuredOutputCaptures, createStructuredOutputFileCapture, createStructuredOutputRuntime, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput, readStructuredOutputAcceptanceReport } from "../shared/structured-output.ts";
108
+ import { clearStructuredOutputCaptures, createStructuredOutputFileCapture, createStructuredOutputRuntime, formatStructuredOutputRejectionError, MISSING_STRUCTURED_OUTPUT_CALL_ERROR, readStructuredOutput, readStructuredOutputAcceptanceReport } from "../shared/structured-output.ts";
109
109
  import { formatMidToolExitError, isOrdinaryToolForMidToolExit, isUnexplainedProcessSignal } from "../shared/process-signal.ts";
110
110
  import { formatChildToolDiagnostic } from "../shared/tool-availability.ts";
111
111
  import { buildTimeoutRecoverySummary, collectTrackedMutationEvidence, snapshotTrackedMutations } from "../shared/mutation-evidence.ts";
@@ -285,6 +285,7 @@ interface StepResult {
285
285
  review?: import("../../shared/types.ts").ReviewProjection;
286
286
  effects?: import("../../shared/types.ts").EffectsProjection;
287
287
  structuredOutput?: unknown;
288
+ structuredOutputFailed?: boolean;
288
289
  structuredOutputPath?: string;
289
290
  structuredOutputSchemaPath?: string;
290
291
  acceptance?: import("../../shared/types.ts").AcceptanceLedger;
@@ -1221,7 +1222,8 @@ export async function runSingleStepInner(
1221
1222
  schemaPath: effectiveStructuredOutput.schemaPath,
1222
1223
  outputPath: effectiveStructuredOutput.outputPath,
1223
1224
  });
1224
- if (structured.error) structuredError = structured.error;
1225
+ if (structured.error === MISSING_STRUCTURED_OUTPUT_CALL_ERROR) structuredError = formatStructuredOutputRejectionError(run.messages);
1226
+ else if (structured.error) structuredError = structured.error;
1225
1227
  else {
1226
1228
  structuredOutput = structured.value;
1227
1229
  const acceptanceReport = readStructuredOutputAcceptanceReport(effectiveStructuredOutput);
@@ -1343,7 +1345,7 @@ export async function runSingleStepInner(
1343
1345
  afterCompactionSettlement: run.afterCompactionSettlement === true,
1344
1346
  });
1345
1347
  const fileMutationEffect = completionEvidence.fileMutation ?? (missingRequiredOutputAfterMutation ? { status: "observed" as const, expected: completionEvidence.mutationExpected, attempted: true, evidence: mutationEvidence } : undefined);
1346
- finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput, runtimeAcknowledgedExtensions, ...(step.agentContract ? { agentContract: step.agentContract } : {}), ...(fileMutationEffect || settlementDiagnostic ? { effects: { ...(fileMutationEffect ? { fileMutation: fileMutationEffect } : {}), ...(settlementDiagnostic ? { settlementDiagnostic } : {}) } } : {}) } as RunChildSessionResult;
1348
+ finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput, structuredOutputFailed: structuredError ? true : undefined, runtimeAcknowledgedExtensions, ...(step.agentContract ? { agentContract: step.agentContract } : {}), ...(fileMutationEffect || settlementDiagnostic ? { effects: { ...(fileMutationEffect ? { fileMutation: fileMutationEffect } : {}), ...(settlementDiagnostic ? { settlementDiagnostic } : {}) } } : {}) } as RunChildSessionResult;
1347
1349
  const abortRecovery = !attempt.success ? planAbortRecovery({
1348
1350
  messages: run.messages,
1349
1351
  error,
@@ -1603,6 +1605,7 @@ export async function runSingleStepInner(
1603
1605
  completionGuardTriggered: completionGuardTriggeredFinal,
1604
1606
  ...((finalResult as (RunChildSessionResult & { effects?: import("../../shared/types.ts").EffectsProjection }) | undefined)?.effects ? { effects: (finalResult as RunChildSessionResult & { effects?: import("../../shared/types.ts").EffectsProjection }).effects } : {}),
1605
1607
  structuredOutput: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : (finalResult as (RunChildSessionResult & { structuredOutput?: unknown }) | undefined)?.structuredOutput,
1608
+ structuredOutputFailed: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : finalResult?.structuredOutputFailed,
1606
1609
  structuredOutputPath: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : effectiveStructuredOutput?.outputPath,
1607
1610
  structuredOutputSchemaPath: timedOutAfterAcceptance || stoppedAfterAcceptance ? undefined : effectiveStructuredOutput?.schemaPath,
1608
1611
  acceptance: effectiveAcceptance,
@@ -3788,6 +3791,7 @@ export async function runSubagent(
3788
3791
  review: pr.review,
3789
3792
  timeoutRecovery: pr.timeoutRecovery,
3790
3793
  structuredOutput: pr.structuredOutput,
3794
+ structuredOutputFailed: pr.structuredOutputFailed,
3791
3795
  structuredOutputPath: pr.structuredOutputPath,
3792
3796
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
3793
3797
  acceptance: pr.acceptance,
@@ -4245,6 +4249,7 @@ export async function runSubagent(
4245
4249
  review: pr.review,
4246
4250
  timeoutRecovery: pr.timeoutRecovery,
4247
4251
  structuredOutput: pr.structuredOutput,
4252
+ structuredOutputFailed: pr.structuredOutputFailed,
4248
4253
  structuredOutputPath: pr.structuredOutputPath,
4249
4254
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
4250
4255
  acceptance: pr.acceptance,
@@ -4539,6 +4544,7 @@ export async function runSubagent(
4539
4544
  review: singleResult.review,
4540
4545
  timeoutRecovery: singleResult.timeoutRecovery,
4541
4546
  structuredOutput: singleResult.structuredOutput,
4547
+ structuredOutputFailed: singleResult.structuredOutputFailed,
4542
4548
  structuredOutputPath: singleResult.structuredOutputPath,
4543
4549
  structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
4544
4550
  acceptance: singleResult.acceptance,
@@ -4923,6 +4929,7 @@ export async function runSubagent(
4923
4929
  review: r.review,
4924
4930
  effects: r.effects,
4925
4931
  structuredOutput: r.structuredOutput,
4932
+ structuredOutputFailed: r.structuredOutputFailed,
4926
4933
  structuredOutputPath: r.structuredOutputPath,
4927
4934
  structuredOutputSchemaPath: r.structuredOutputSchemaPath,
4928
4935
  acceptance: r.acceptance,
@@ -0,0 +1,67 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AsyncStatus, ProcessTerminal, WorkflowChildSummary, WorkflowTerminalProof } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
5
+ import { readProcessTerminal } from "./process-terminal.ts";
6
+
7
+ const TERMINAL_WORKFLOW_STATES = new Set<WorkflowChildSummary["workflowState"]>(["completed", "failed", "stopped"]);
8
+
9
+ export function isTerminalAsyncState(state: AsyncStatus["state"]): boolean {
10
+ return state !== "queued" && state !== "running" && state !== "paused";
11
+ }
12
+
13
+ export type WorkflowChildProcessEvidence =
14
+ | { state: "observed"; children: ProcessTerminal[] }
15
+ | { state: "pending" | "unknown"; reason: string };
16
+
17
+ /**
18
+ * Process evidence for a workflow's async children. Status proofs and capacity
19
+ * release both use this so they cannot disagree about the same workflow.
20
+ * Synchronous children run inside the workflow host and have no process of their own.
21
+ */
22
+ export function readWorkflowChildProcessEvidence(workflowAsyncDir: string, steps: AsyncStatus["steps"]): WorkflowChildProcessEvidence {
23
+ const children: ProcessTerminal[] = [];
24
+ for (const step of steps ?? []) {
25
+ const label = step.workflowKey ?? step.agent;
26
+ if (typeof step.async !== "boolean") return { state: "unknown", reason: `workflow child ${label} is missing async classification` };
27
+ if (!step.async) continue;
28
+ if (!step.runId || path.basename(step.runId) !== step.runId) return { state: "unknown", reason: `async workflow child ${label} is missing run id` };
29
+ const childDir = path.join(path.dirname(workflowAsyncDir), step.runId);
30
+ if (!fs.existsSync(childDir)) return { state: "unknown", reason: `async workflow child ${label} directory is missing` };
31
+ const childStatus = readStatus(childDir);
32
+ if (!childStatus) return { state: "unknown", reason: `async workflow child ${label} status is missing or unreadable` };
33
+ if (!isTerminalAsyncState(childStatus.state)) return { state: "pending", reason: `async workflow child ${label} is still ${childStatus.state}` };
34
+ const recorded = childStatus.processTerminal;
35
+ if (!recorded?.runnerProcessInstanceId) return { state: "unknown", reason: `async workflow child ${label} has no runner process identity` };
36
+ // A runner startup failure records `not-started` in status and never writes a process-terminal sidecar.
37
+ if (recorded.state === "not-started" && recorded.runId === step.runId && typeof childStatus.error === "string" && childStatus.error) {
38
+ children.push(recorded);
39
+ continue;
40
+ }
41
+ const proof = readProcessTerminal(childDir, { runId: step.runId, runnerProcessInstanceId: recorded.runnerProcessInstanceId });
42
+ if (proof?.state !== "observed" || proof.runId !== step.runId) {
43
+ return { state: !proof || proof.state === "pending" ? "pending" : "unknown", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
44
+ }
45
+ children.push(proof);
46
+ }
47
+ return { state: "observed", children };
48
+ }
49
+
50
+ function unresolved(runId: string, state: "pending" | "unknown", dispatchClosed: boolean, reason: string): WorkflowTerminalProof {
51
+ return { version: 1, kind: "workflow", runId, state, dispatchClosed, reason };
52
+ }
53
+
54
+ /** A persistent workflow host is terminal only after dispatch closes and every async child has process evidence. */
55
+ export function readWorkflowTerminalProof(asyncDir: string, steps: AsyncStatus["steps"], summary: WorkflowChildSummary, hostCommandCount: number, closedAt: number): WorkflowTerminalProof {
56
+ const runId = summary.workflowRunId;
57
+ if (!summary.inventoryComplete || !TERMINAL_WORKFLOW_STATES.has(summary.workflowState)) {
58
+ return unresolved(runId, "pending", false, "Workflow dispatch is still open.");
59
+ }
60
+ if (hostCommandCount > 0) {
61
+ return unresolved(runId, "unknown", true, "Workflow host commands have no process-terminal proof.");
62
+ }
63
+ const evidence = readWorkflowChildProcessEvidence(asyncDir, steps);
64
+ if (evidence.state !== "observed") return unresolved(runId, evidence.state, true, evidence.reason);
65
+ const observedAt = Math.max(closedAt, ...evidence.children.map((child) => child.state === "observed" ? child.observedAt : 0));
66
+ return { version: 1, kind: "workflow", runId, state: "observed", dispatchClosed: true, observedAt, children: evidence.children };
67
+ }