@tranhoangnguyen0310/pi-flow-external 5.1.0-external.0 → 5.2.0-external.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to pi-flow external are documented here.
4
4
 
5
+ ## [5.2.0-external.0] - 2026-10-20
6
+
7
+ ### Changed
8
+ - Requires Pi 1.3: peer dependencies on `@earendil-works/pi-agent-core`, `pi-ai`, `pi-coding-agent` and `pi-tui` move from `~0.99.2` to `~1.3.0`.
9
+ - Node 26 is supported and tested in CI. `npm run e2e` no longer passes `--experimental-transform-types`, which Node 26 removed; the sources now use erasable TypeScript syntax only and load through Node's built-in type stripping.
10
+
11
+ ### Fixed
12
+ - `external_delegate` and `external_workflow` no longer disappear from a session saved before the #85 rename. Pi restores a resumed or tree-navigated session's tool loadout from its transcript, and the legacy names `Agent` and `workflow` no longer register. Each legacy name the restored loadout declares now activates its replacement. A launcher stays off when the loadout also declares its current name, or when `--tools -<name>` or `--no-tools` excludes it.
13
+
14
+ ## [5.1.1-external.0] - 2026-10-20
15
+
16
+ ### Changed
17
+ - Simplified `external_runs` action-specific argument guidance (#87):
18
+ - Consolidated `externalRunsParameters`, `EXTERNAL_RUNS_PARAMETER_KEYS`, `EXTERNAL_RUNS_EXAMPLES`, `EXTERNAL_RUNS_ACTION_SPECS`, and `formatExternalRunsActionGuidance()` in `src/contract/runs.ts` so schema descriptions, on-demand `external_help` (`usage` and `workflow`), and documentation share one authoritative per-action argument matrix (`required`, `optional`, and dynamically derived `inapplicable` fields).
19
+ - Clarified single-target `inspect` (`1` ID, views `summary|output|diagnostics|final|launch`) vs. batch `inspect` (`2-20` IDs, `summary` view only), and `cursor` (`runs` stream) vs. `workflowCursor` (`workflows` stream, inapplicable when `workflowRunId` filters to one workflow's children).
20
+ - Updated `request_invalid` and `cursor_invalid` error messages across `list`, `inspect`, `wait`, and `cancel` to identify the applicable action and include a minimal valid JSON call example while preserving #62 forced-placeholder normalization and supervision invariants.
21
+
5
22
  ## [5.1.0-external.0] - 2026-10-19
6
23
 
7
24
  ### Added
@@ -108,27 +108,37 @@ return { run, view, encoding, text, checklist };
108
108
 
109
109
  ## The `external_runs` Tool
110
110
 
111
- Use `external_runs` to supervise, inspect, and manage runs programmatically:
111
+ Use `external_runs` to supervise, inspect, and manage runs programmatically. Only `action` is top-level required in the JSON Schema; omit parameters that do not apply to the chosen action:
112
+
113
+ | Action | Required | Optional | Inapplicable (omit) |
114
+ | --- | --- | --- | --- |
115
+ | `list` | `action` | `workflowRunId`, `cursor`, `workflowCursor`, `limit` | `runIds`, `view`, `limitBytes`, `mode`, `reason` |
116
+ | `list` (with `workflowRunId`) | `action`, `workflowRunId` | `cursor`, `limit` | `workflowCursor`, `runIds`, `view`, `limitBytes`, `mode`, `reason` |
117
+ | `inspect` (single run: 1 ID) | `action`, `runIds` (`[id]`) | `view` (`summary` \| `output` \| `diagnostics` \| `final` \| `launch`), `cursor`, `limitBytes` | `workflowRunId`, `workflowCursor`, `limit`, `mode`, `reason` |
118
+ | `inspect` (batch: 2-20 IDs) | `action`, `runIds` (`2-20` IDs) | `view` (`"summary"` only), `cursor`, `limitBytes` | `workflowRunId`, `workflowCursor`, `limit`, `mode`, `reason` |
119
+ | `wait` | `action`, `runIds` (`1-100` IDs) | `mode` (`"any"` \| `"all"`), `limitBytes` | `workflowRunId`, `view`, `cursor`, `workflowCursor`, `limit`, `reason` |
120
+ | `cancel` | `action`, `runIds` (`[id]`, `1` ID) | `reason` | `workflowRunId`, `view`, `cursor`, `workflowCursor`, `limit`, `limitBytes`, `mode` |
112
121
 
113
122
  ### 1. `list`
114
- Page session-owned runs and workflow roots:
123
+ Page session-owned agent runs and workflow roots:
115
124
  ```ts
116
125
  await external_runs({
117
126
  action: "list",
118
127
  limit: 10,
119
- cursor: undefined, // pass nextCursor for pagination
120
128
  });
121
129
  ```
122
- Each entry returns timing projections (`queueDelayMs`, `elapsedMs`, `activityAgeMs`, `processDurationMs`).
130
+ - **`cursor` vs. `workflowCursor`:** `list` returns two independent paged streams: `runs` (paged with `cursor` from `nextCursor`) and `workflows` (paged with `workflowCursor` from `nextWorkflowCursor`).
131
+ - **`workflowRunId` filtering:** Passing `workflowRunId: "wf_..."` filters `runs` to that workflow's child agent runs and returns `workflows: []`, so only `cursor` applies (`workflowCursor` is inapplicable).
132
+ - Each entry returns timing projections (`queueDelayMs`, `elapsedMs`, `activityAgeMs`, `processDurationMs`).
123
133
 
124
134
  ### 2. `inspect`
125
135
  Inspect a specific run's state and streams:
126
136
  ```ts
127
- // Single-run inspection supports any view:
137
+ // Single-run inspection (1 ID) supports any view:
128
138
  await external_runs({
129
139
  action: "inspect",
130
140
  runIds: [runId],
131
- view: "summary", // "summary" | "output" | "diagnostics" | "final" | "launch"
141
+ view: "final", // "summary" | "output" | "diagnostics" | "final" | "launch"
132
142
  limitBytes: 32768,
133
143
  });
134
144
  ```
@@ -138,17 +148,16 @@ await external_runs({
138
148
  - `final`: **Verified canonical result only**. Returns empty with `finalAvailable: false` until the child reaches verified terminal success.
139
149
  - `launch`: Detailed initial launch prompt, configuration, and shared context snapshot.
140
150
 
141
- **Batch Inspection (Summary-only):** Pass up to 20 IDs to check multiple background children in a single cheap call:
151
+ **Batch Inspection (`2-20` IDs, `summary` view only):** Pass 2 to 20 IDs in `runIds` to check multiple background runs in a single bounded call (non-`summary` views require a single target):
142
152
  ```ts
143
153
  await external_runs({
144
154
  action: "inspect",
145
155
  runIds: [runId1, runId2],
146
- view: "summary", // Batch inspect is summary-only
147
156
  });
148
157
  ```
149
158
 
150
159
  ### 3. `wait`
151
- Block until one or more targets reach terminal state:
160
+ Block until one or more targets (`1-100` IDs in `runIds`) reach terminal state:
152
161
  ```ts
153
162
  const waitResult = await external_runs({
154
163
  action: "wait",
@@ -162,7 +171,7 @@ const waitResult = await external_runs({
162
171
  - **Shared Budget:** The `limitBytes` cap is spent across settled results in requested order. If a result exceeds remaining space, it returns `resultTruncated: true` with references to inspect the rest.
163
172
 
164
173
  ### 4. `cancel`
165
- Cancel an active run:
174
+ Cancel a single active run (`1` ID via `runIds: [runId]` or `runId`):
166
175
  ```ts
167
176
  await external_runs({
168
177
  action: "cancel",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tranhoangnguyen0310/pi-flow-external",
3
- "version": "5.1.0-external.0",
3
+ "version": "5.2.0-external.0",
4
4
  "description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -41,22 +41,22 @@
41
41
  "scripts": {
42
42
  "build": "tsc",
43
43
  "check": "tsc --noEmit && vitest run",
44
- "e2e": "node --experimental-transform-types scripts/e2e/external.mjs",
44
+ "e2e": "node scripts/e2e/external.mjs",
45
45
  "field-report": "node scripts/field-report.mjs",
46
46
  "test": "vitest run"
47
47
  },
48
48
  "peerDependencies": {
49
- "@earendil-works/pi-agent-core": "~0.99.2",
50
- "@earendil-works/pi-ai": "~0.99.2",
51
- "@earendil-works/pi-coding-agent": "~0.99.2",
52
- "@earendil-works/pi-tui": "~0.99.2",
49
+ "@earendil-works/pi-agent-core": "~1.3.0",
50
+ "@earendil-works/pi-ai": "~1.3.0",
51
+ "@earendil-works/pi-coding-agent": "~1.3.0",
52
+ "@earendil-works/pi-tui": "~1.3.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
- "@earendil-works/pi-agent-core": "0.99.2",
57
- "@earendil-works/pi-ai": "0.99.2",
58
- "@earendil-works/pi-coding-agent": "0.99.2",
59
- "@earendil-works/pi-tui": "0.99.2",
56
+ "@earendil-works/pi-agent-core": "1.3.0",
57
+ "@earendil-works/pi-ai": "1.3.0",
58
+ "@earendil-works/pi-coding-agent": "1.3.0",
59
+ "@earendil-works/pi-tui": "1.3.0",
60
60
  "@types/node": "^24.12.4",
61
61
  "typebox": "1.1.38",
62
62
  "typescript": "^5.9.3",
@@ -23,7 +23,13 @@ export class ConfigModal implements Component {
23
23
  private remember = false;
24
24
  private cancelBusy?: () => void;
25
25
  focused = true;
26
- constructor(private tui: TUI, private theme: Theme, private cancelled: (data:string)=>boolean = data=>matchesKey(data,Key.escape), private externalEditorKey:(data:string)=>boolean = ()=>false) {}
26
+ private tui: TUI;
27
+ private theme: Theme;
28
+ private cancelled: (data:string)=>boolean;
29
+ private externalEditorKey: (data:string)=>boolean;
30
+ constructor(tui: TUI, theme: Theme, cancelled: (data:string)=>boolean = data=>matchesKey(data,Key.escape), externalEditorKey:(data:string)=>boolean = ()=>false) {
31
+ this.tui = tui; this.theme = theme; this.cancelled = cancelled; this.externalEditorKey = externalEditorKey;
32
+ }
27
33
  private listTheme() {
28
34
  return { selectedPrefix: (s:string)=>this.theme.fg('accent',s), selectedText: (s:string)=>this.theme.fg('accent',s), description:(s:string)=>this.theme.fg('muted',s),scrollInfo:(s:string)=>this.theme.fg('dim',s),noMatch:(s:string)=>this.theme.fg('warning',s) };
29
35
  }
@@ -1,3 +1,4 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { Type } from "typebox";
2
3
  import { failureVariant, strings, successVariant } from "./envelope.ts";
3
4
  import { supervisedRunSchema } from "./run.ts";
@@ -6,7 +7,177 @@ const TOOL = "external_runs";
6
7
  const runs = Type.Array(supervisedRunSchema);
7
8
  const cursor = Type.Optional(Type.String());
8
9
 
10
+ export const EXTERNAL_RUNS_ACTIONS = ["list", "inspect", "wait", "cancel"] as const;
9
11
  export const SINGLE_INSPECT_VIEWS = ["summary", "output", "diagnostics", "final", "launch"] as const;
12
+ export const WAIT_MODES = ["any", "all"] as const;
13
+ export const MAX_WAIT_TARGETS = 100;
14
+ /**
15
+ * Cap for `inspect`'s batch `runIds` mode. Intentionally lower than `wait`'s
16
+ * MAX_WAIT_TARGETS (100): batch inspection returns full projections and may
17
+ * touch live evidence reads per target, so it stays cheap by staying small.
18
+ */
19
+ export const MAX_BATCH_INSPECT_TARGETS = 20;
20
+
21
+ export const externalRunsParameters = Type.Object({
22
+ action: StringEnum(EXTERNAL_RUNS_ACTIONS, {
23
+ description:
24
+ "Required action. list: page session runs and workflow roots (optional: workflowRunId, limit, cursor, workflowCursor; omit runIds, view, mode, limitBytes, reason). inspect: read 1 run with any view or 2-20 runs for batch summary (required: runIds; optional: view, cursor, limitBytes; omit mode, workflowRunId, workflowCursor, limit, reason). wait: block until terminal outcomes for 1-100 runs (required: runIds; optional: mode, limitBytes; omit view, cursor, workflowCursor, limit, workflowRunId, reason). cancel: stop 1 run (required: runIds with 1 ID; optional: reason; omit view, mode, cursor, workflowCursor, limit, limitBytes, workflowRunId).",
25
+ }),
26
+ runIds: Type.Optional(Type.Array(Type.String(), {
27
+ maxItems: MAX_WAIT_TARGETS,
28
+ description:
29
+ "Target run IDs (run_... agent, wf_... workflow). Required for inspect (1 ID for any view: summary, output, diagnostics, final, launch; 2-20 IDs for summary-only batch), wait (1-100 IDs), and cancel (1 ID). Inapplicable to list (omit).",
30
+ })),
31
+ view: Type.Optional(StringEnum(SINGLE_INSPECT_VIEWS, {
32
+ description:
33
+ "Optional for inspect only (default \"summary\"); inapplicable to list, wait, and cancel (omit). Single-target inspect (1 ID) accepts \"summary\" (state/timing/freshness/refs, default), \"output\" (assistant text plus canonical result, partial or final), \"diagnostics\" (tool activity/errors), \"final\" (only the verified canonical terminal answer, empty until a successful terminal boundary exists), or \"launch\" (recorded prompt, context and execution configuration; sensitive, explicit inspection only). Batch inspect (2-20 runIds) only supports \"summary\".",
34
+ })),
35
+ mode: Type.Optional(StringEnum(WAIT_MODES, {
36
+ description:
37
+ "Optional for wait only (\"any\" | \"all\", default \"all\"); inapplicable to list, inspect, and cancel (omit). any returns on the first terminal outcome; all waits for every target. Neither cancels pending work; an unsuccessful selected workflow returns early even in all mode.",
38
+ })),
39
+ cursor: Type.Optional(Type.String({
40
+ description:
41
+ "Optional continuation cursor for list and inspect; inapplicable to wait and cancel (omit). For list: pass back nextCursor verbatim to page the runs stream (all agent runs, or that workflow's children when workflowRunId is set). For single inspect: pass back page.nextCursor with the same runId and view. For batch inspect: pass back nextCursor with the identical runIds array. Stale/reused cursors fail with an actionable error.",
42
+ })),
43
+ workflowCursor: Type.Optional(Type.String({
44
+ description:
45
+ "Optional continuation cursor for unfiltered list only; inapplicable when workflowRunId filters to one workflow's children and for inspect, wait, and cancel (omit). Pass back nextWorkflowCursor verbatim to page the workflow-roots listing (workflows); passing workflowCursor without cursor pages workflows only.",
46
+ })),
47
+ limit: Type.Optional(Type.Integer({
48
+ minimum: 1,
49
+ maximum: 100,
50
+ description: "Optional for list only (1-100); inapplicable to inspect, wait, and cancel (omit). Max entries per list/children page.",
51
+ })),
52
+ limitBytes: Type.Optional(Type.Integer({
53
+ minimum: 4,
54
+ maximum: 65536,
55
+ description:
56
+ "Optional for inspect and wait (4-65536); inapplicable to list and cancel (omit). For inspect: max UTF-8 bytes per page, including the summary view; follow nextCursor for the remainder. For wait: total result budget shared across every settled outcome in the response in request order (default 32768); a result that fits is returned complete, one that does not is truncated with resultTruncated:true and outputRef/diagnosticsRef for the rest.",
57
+ })),
58
+ workflowRunId: Type.Optional(Type.String({
59
+ description:
60
+ "Optional for list only (wf_... ID); inapplicable to inspect, wait, and cancel (omit). Filters list.runs to children of this wf_... workflow only, leaves list.workflows empty, and pages via cursor (nextCursor) rather than workflowCursor.",
61
+ })),
62
+ reason: Type.Optional(Type.String({
63
+ maxLength: 512,
64
+ description: "Optional for cancel only (max 512 chars); inapplicable to list, inspect, and wait (omit). Cancellation reason recorded with the run evidence.",
65
+ })),
66
+ });
67
+
68
+ export type ExternalRunsParameterKey = keyof typeof externalRunsParameters.properties;
69
+
70
+ export const EXTERNAL_RUNS_PARAMETER_KEYS = Object.keys(
71
+ externalRunsParameters.properties,
72
+ ) as readonly ExternalRunsParameterKey[];
73
+
74
+ export const EXTERNAL_RUNS_EXAMPLES = {
75
+ list: { action: "list" as const },
76
+ listWorkflowChildren: { action: "list" as const, workflowRunId: "wf_example" },
77
+ listRunsNextPage: { action: "list" as const, cursor: "cursor_from_nextCursor" },
78
+ listWorkflowsNextPage: { action: "list" as const, workflowCursor: "cursor_from_nextWorkflowCursor" },
79
+ inspectSingle: { action: "inspect" as const, runIds: ["run_example"], view: "final" as const },
80
+ inspectBatch: { action: "inspect" as const, runIds: ["run_example_1", "run_example_2"] },
81
+ wait: { action: "wait" as const, runIds: ["run_example"], mode: "all" as const },
82
+ cancel: { action: "cancel" as const, runIds: ["run_example"] },
83
+ cancelWithReason: { action: "cancel" as const, runIds: ["run_example"], reason: "No longer needed" },
84
+ };
85
+
86
+ export type ExternalRunsExample = (typeof EXTERNAL_RUNS_EXAMPLES)[keyof typeof EXTERNAL_RUNS_EXAMPLES];
87
+
88
+ export interface ExternalRunsActionSpec {
89
+ id: "list" | "list_children" | "inspect_single" | "inspect_batch" | "wait" | "cancel";
90
+ label: string;
91
+ action: (typeof EXTERNAL_RUNS_ACTIONS)[number];
92
+ required: readonly ExternalRunsParameterKey[];
93
+ optional: readonly ExternalRunsParameterKey[];
94
+ inapplicable: readonly ExternalRunsParameterKey[];
95
+ rule: string;
96
+ example: ExternalRunsExample;
97
+ }
98
+
99
+ function defineActionSpec(spec: Omit<ExternalRunsActionSpec, "inapplicable">): ExternalRunsActionSpec {
100
+ const allowed = new Set<ExternalRunsParameterKey>([...spec.required, ...spec.optional]);
101
+ return {
102
+ ...spec,
103
+ inapplicable: EXTERNAL_RUNS_PARAMETER_KEYS.filter((key) => !allowed.has(key)),
104
+ };
105
+ }
106
+
107
+ export const EXTERNAL_RUNS_ACTION_SPECS: readonly ExternalRunsActionSpec[] = [
108
+ defineActionSpec({
109
+ id: "list",
110
+ label: "list (session runs + workflow roots)",
111
+ action: "list",
112
+ required: ["action"],
113
+ optional: ["limit", "cursor", "workflowCursor", "workflowRunId"],
114
+ rule: "Two independent cursor streams: cursor (from nextCursor) pages runs; workflowCursor (from nextWorkflowCursor) pages workflows. Passing workflowCursor without cursor pages workflows alone.",
115
+ example: EXTERNAL_RUNS_EXAMPLES.list,
116
+ }),
117
+ defineActionSpec({
118
+ id: "list_children",
119
+ label: "list (workflow children via workflowRunId)",
120
+ action: "list",
121
+ required: ["action", "workflowRunId"],
122
+ optional: ["limit", "cursor"],
123
+ rule: "workflowRunId (wf_...) filters runs to that workflow's children and returns workflows: []; page with cursor (nextCursor) only, never workflowCursor.",
124
+ example: EXTERNAL_RUNS_EXAMPLES.listWorkflowChildren,
125
+ }),
126
+ defineActionSpec({
127
+ id: "inspect_single",
128
+ label: "inspect (single run, any view)",
129
+ action: "inspect",
130
+ required: ["action", "runIds"],
131
+ optional: ["view", "cursor", "limitBytes"],
132
+ rule: `1 ID in runIds (legacy runId also accepted); view is ${SINGLE_INSPECT_VIEWS.join("|")} (default summary); cursor continues from page.nextCursor with the same runId and view.`,
133
+ example: EXTERNAL_RUNS_EXAMPLES.inspectSingle,
134
+ }),
135
+ defineActionSpec({
136
+ id: "inspect_batch",
137
+ label: `inspect (batch summary, 2-${MAX_BATCH_INSPECT_TARGETS} runs)`,
138
+ action: "inspect",
139
+ required: ["action", "runIds"],
140
+ optional: ["view", "cursor", "limitBytes"],
141
+ rule: `2-${MAX_BATCH_INSPECT_TARGETS} IDs in runIds (1 ID with default/summary view also uses batch shape); view must be omitted or "summary" (output, diagnostics, final, and launch are rejected); cursor continues from nextCursor with the identical runIds array.`,
142
+ example: EXTERNAL_RUNS_EXAMPLES.inspectBatch,
143
+ }),
144
+ defineActionSpec({
145
+ id: "wait",
146
+ label: `wait (1-${MAX_WAIT_TARGETS} runs)`,
147
+ action: "wait",
148
+ required: ["action", "runIds"],
149
+ optional: ["mode", "limitBytes"],
150
+ rule: `1-${MAX_WAIT_TARGETS} IDs in runIds; mode is ${WAIT_MODES.join("|")} (default all); limitBytes is the shared result budget spent in request order.`,
151
+ example: EXTERNAL_RUNS_EXAMPLES.wait,
152
+ }),
153
+ defineActionSpec({
154
+ id: "cancel",
155
+ label: "cancel (1 run)",
156
+ action: "cancel",
157
+ required: ["action", "runIds"],
158
+ optional: ["reason"],
159
+ rule: "1 ID in runIds (legacy runId also accepted); reason is optional (<=512 chars).",
160
+ example: EXTERNAL_RUNS_EXAMPLES.cancelWithReason,
161
+ }),
162
+ ];
163
+
164
+ /**
165
+ * Compact per-action argument matrix and minimal valid examples generated from
166
+ * `externalRunsParameters` and `EXTERNAL_RUNS_ACTION_SPECS` so on-demand help
167
+ * cannot drift from the executable tool contract.
168
+ */
169
+ export function formatExternalRunsActionGuidance(): string {
170
+ const rows = EXTERNAL_RUNS_ACTION_SPECS.map((spec) => [
171
+ `- ${spec.label}: required: ${spec.required.join(", ")} | optional: ${spec.optional.join(", ") || "none"} | omit: ${spec.inapplicable.join(", ") || "none"}`,
172
+ ` Rule: ${spec.rule}`,
173
+ ` Minimal call: ${JSON.stringify(spec.example)}`,
174
+ ].join("\n"));
175
+ return [
176
+ "external_runs action-specific arguments (supply only the relevant fields; omit inapplicable ones):",
177
+ ...rows,
178
+ `List pagination examples: runs stream -> ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}; workflow roots stream -> ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}.`,
179
+ ].join("\n");
180
+ }
10
181
 
11
182
  /** One success variant per action (inspect has batch and single shapes); every expected failure has `data: null`. */
12
183
  export const externalRunsOutputSchema = Type.Union([
@@ -28,7 +199,8 @@ export const externalRunsOutputSchema = Type.Union([
28
199
  outputStatus: Type.Optional(strings("preliminary", "final", "interrupted")),
29
200
  }),
30
201
  ])),
31
- successVariant(TOOL, Type.Literal("wait"), Type.Object({ mode: strings("any", "all"), completed: runs, pending: Type.Array(Type.String()) })),
202
+ successVariant(TOOL, Type.Literal("wait"), Type.Object({ mode: strings(...WAIT_MODES), completed: runs, pending: Type.Array(Type.String()) })),
32
203
  successVariant(TOOL, Type.Literal("cancel"), Type.Object({ runId: Type.String(), status: strings("requested", "terminal") })),
33
- failureVariant(TOOL, strings("list", "inspect", "wait", "cancel"), Type.Null()),
204
+ failureVariant(TOOL, strings(...EXTERNAL_RUNS_ACTIONS), Type.Null()),
34
205
  ]);
206
+
@@ -42,10 +42,13 @@ export class ConcurrencyLimiter {
42
42
  private active = 0;
43
43
  private readonly waiters: Waiter[] = [];
44
44
 
45
- constructor(readonly max: number) {
45
+ readonly max: number;
46
+
47
+ constructor(max: number) {
46
48
  if (!Number.isInteger(max) || max < 1) {
47
49
  throw new Error("ConcurrencyLimiter max must be a positive integer");
48
50
  }
51
+ this.max = max;
49
52
  }
50
53
 
51
54
  get activeCount(): number {
@@ -8,8 +8,11 @@ export type FlowErrorCode = (typeof FLOW_ERROR_CODES)[number];
8
8
 
9
9
  /** Expected caller/configuration failure, distinct from a programming error. */
10
10
  export class ExpectedFlowError extends Error {
11
- constructor(readonly code: FlowErrorCode, message: string) {
11
+ readonly code: FlowErrorCode;
12
+
13
+ constructor(code: FlowErrorCode, message: string) {
12
14
  super(message);
15
+ this.code = code;
13
16
  this.name = "ExpectedFlowError";
14
17
  }
15
18
  }
@@ -839,7 +839,7 @@ function encodeCursor(cursor: InspectionCursor | ListCursor): string {
839
839
  }
840
840
 
841
841
  function decodeCursor(value: string): InspectionCursor | ListCursor {
842
- if (!value || value.length > MAX_CURSOR_CHARS) throw cursorInvalid();
842
+ if (typeof value !== "string" || !value || value.length > MAX_CURSOR_CHARS) throw cursorInvalid();
843
843
  let parsed: Record<string, unknown> | undefined;
844
844
  try {
845
845
  parsed = asRecord(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
@@ -80,11 +80,16 @@ export class RunRegistry {
80
80
  private readonly closedSessions = new Set<string>();
81
81
  private readonly backgroundListeners = new Set<(event: BackgroundRunEvent) => void>();
82
82
 
83
- constructor(private readonly completedLimit = DEFAULT_COMPLETED_LIMIT, private readonly shutdownGraceMs = 4_000) {
83
+ private readonly completedLimit: number;
84
+ private readonly shutdownGraceMs: number;
85
+
86
+ constructor(completedLimit = DEFAULT_COMPLETED_LIMIT, shutdownGraceMs = 4_000) {
84
87
  if (!Number.isInteger(completedLimit) || completedLimit < 0) {
85
88
  throw new Error("completed run limit must be a non-negative integer");
86
89
  }
87
90
  if (!Number.isInteger(shutdownGraceMs) || shutdownGraceMs < 0) throw new Error("shutdown grace must be a non-negative integer");
91
+ this.completedLimit = completedLimit;
92
+ this.shutdownGraceMs = shutdownGraceMs;
88
93
  }
89
94
 
90
95
  sessionVersion(sessionId: string): number {
@@ -1090,8 +1090,11 @@ type ExternalRunsResult = { content: Array<{ type: string; text?: string }>; det
1090
1090
 
1091
1091
  /** A returned external_runs failure, raised with its code so callers recover by code, never by message. */
1092
1092
  class RunsActionError extends Error {
1093
- constructor(readonly code: string, message: string) {
1093
+ readonly code: string;
1094
+
1095
+ constructor(code: string, message: string) {
1094
1096
  super(message);
1097
+ this.code = code;
1095
1098
  }
1096
1099
  }
1097
1100
 
@@ -7,6 +7,7 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type, type Static } from "typebox";
9
9
  import { filterProfilesForModelRegistry } from "./core/model.ts";
10
+ import { formatExternalRunsActionGuidance } from "./contract/runs.ts";
10
11
  import { disabledHarnessMessage, loadExternalCatalog } from "./profiles.ts";
11
12
  import { loadHarnessConfigs } from "./harnesses.ts";
12
13
  import {
@@ -103,6 +104,8 @@ Execution mode: foreground is blocking by default when you need the result next,
103
104
 
104
105
  Supervision: external_runs selects targets with runIds. list takes optional workflowRunId, cursor, workflowCursor, and limit. inspect with one id reads summary/output/diagnostics/final/launch and pages with cursor and limitBytes; several ids (up to 20) batch summary only. wait takes up to 100 runIds in mode any or all; check ok for the operation, then each completed run's outcome and pending IDs. It never cancels pending work and can return early on a failed workflow even in all mode. If settlement is already known, skip wait. Use inline successful output when available; otherwise inspect final and follow nextCursor. Failures need diagnostics/output, not final. Follow nextWorkflowCursor for workflow listings too. Foreground-call interruption cancels that call; interrupting wait only stops waiting. Use cancel with one id and an optional reason to stop background work. Cancelling a workflow stops active children; cancelling one child is a catchable workflow error. There is no live steering.
105
106
 
107
+ ${formatExternalRunsActionGuidance()}
108
+
106
109
  Replay: resumeFromRunId works only with persisted scriptPath and starts an explicit new attempt. It reuses the longest unchanged prefix of successful child calls; the first changed/failed/cancelled/timed-out call and its suffix run again. Script recomposition is cheap, but child reruns can cost money or repeat side effects. There is no automatic repaired-script replay.
107
110
 
108
111
  Background is an external_workflow tool call parameter ({ script, background: true }), never a meta field — meta.background is silently ignored and does not run the workflow in the background.
@@ -1,7 +1,6 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
2
1
  import { type ExtensionContext, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
2
  import { Container, Text } from "@earendil-works/pi-tui";
4
- import { Type, type Static } from "typebox";
3
+ import { type Static } from "typebox";
5
4
  import { createHash } from "node:crypto";
6
5
  import { resolve } from "node:path";
7
6
  import { getRunRecord, inspectRun, listRunRecords, type RunInspectionView, type RunRecordListItem } from "./core/run-inspection.ts";
@@ -13,7 +12,16 @@ import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
13
12
  import { EXTERNAL_RUNS_PROMPT_SNIPPET } from "./prompts.ts";
14
13
  import { envelope, INLINE_RESULT_BYTES, redactedText, withContract } from "./contract/envelope.ts";
15
14
  import { durableAgentRun, journalWorkflowRun, liveAgentRun, liveWorkflowRun, unreadableWorkflowRun, type EvidenceIntegrity, type PublicRun } from "./contract/run.ts";
16
- import { externalRunsOutputSchema } from "./contract/runs.ts";
15
+ import {
16
+ EXTERNAL_RUNS_ACTIONS,
17
+ EXTERNAL_RUNS_EXAMPLES,
18
+ externalRunsOutputSchema,
19
+ externalRunsParameters,
20
+ MAX_BATCH_INSPECT_TARGETS,
21
+ MAX_WAIT_TARGETS,
22
+ SINGLE_INSPECT_VIEWS,
23
+ WAIT_MODES,
24
+ } from "./contract/runs.ts";
17
25
  import { ExpectedFlowError } from "./core/errors.ts";
18
26
  import { redactSecrets } from "./core/run-record.ts";
19
27
  import type { WorkflowToolDetails } from "./types.ts";
@@ -21,52 +29,6 @@ import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, Workf
21
29
 
22
30
  const RUN_ID = /^(?:run|wf)_[A-Za-z0-9_-]{1,128}$/;
23
31
  const WORKFLOW_ID = /^wf_[A-Za-z0-9_-]{1,128}$/;
24
- const MAX_TARGETS = 100;
25
- /**
26
- * Cap for `inspect`'s batch `runIds` mode. Intentionally lower than `wait`'s
27
- * MAX_TARGETS (100): batch inspection returns full projections and may touch
28
- * live evidence reads per target, so it stays cheap by staying small.
29
- */
30
- const MAX_BATCH_INSPECT_TARGETS = 20;
31
-
32
- const externalRunsParameters = Type.Object({
33
- action: StringEnum(["list", "inspect", "wait", "cancel"] as const, {
34
- description: "list: page runs/workflows; inspect: read one run; wait: block until selected terminal outcomes; cancel: stop one run.",
35
- }),
36
- runIds: Type.Optional(Type.Array(Type.String(), {
37
- maxItems: MAX_TARGETS,
38
- description: "Target run IDs (run_... agent, wf_... workflow). For inspect: a single-entry list [\"run_...\"] inspects that run with any view (summary, output, diagnostics, final); multiple entries (1-20 targets) batch summary inspect. For cancel: a single-entry list [\"run_...\"]. For wait: any|all of up to 100 targets.",
39
- })),
40
- view: Type.Optional(StringEnum(["summary", "output", "diagnostics", "final", "launch"] as const, {
41
- description: "inspect view: launch (recorded prompt, context and execution configuration; sensitive, explicit inspection only), summary (state/timing/freshness/refs, default), output (assistant text plus canonical result, partial or final), diagnostics (tool activity/errors), final (only the verified canonical terminal answer, empty until a successful terminal boundary exists). Batch inspect (runIds) only supports summary.",
42
- })),
43
- mode: Type.Optional(StringEnum(["any", "all"] as const, {
44
- description: "wait mode: any returns on the first terminal outcome; all waits for every target. Neither cancels pending work; an unsuccessful selected workflow returns early even in all mode.",
45
- })),
46
- cursor: Type.Optional(Type.String({
47
- description: "Opaque continuation cursor from a prior response; pass back verbatim to page. Stale/reused cursors fail with an actionable error.",
48
- })),
49
- workflowCursor: Type.Optional(Type.String({
50
- description: "Opaque cursor paging the workflow-roots listing (list action); pass back verbatim. Not used when workflowRunId filters to one workflow's children.",
51
- })),
52
- limit: Type.Optional(Type.Integer({
53
- minimum: 1,
54
- maximum: 100,
55
- description: "Max entries per list/children page.",
56
- })),
57
- limitBytes: Type.Optional(Type.Integer({
58
- minimum: 4,
59
- maximum: 65536,
60
- description: "Max bytes per inspect page, including the summary view; follow nextCursor for the remainder. For wait, this is the total result budget shared across every settled outcome in the response (default 32768): a result that fits is returned complete, one that does not is truncated with resultTruncated:true and outputRef/diagnosticsRef for the rest.",
61
- })),
62
- workflowRunId: Type.Optional(Type.String({
63
- description: "list filter: children of this wf_... workflow only.",
64
- })),
65
- reason: Type.Optional(Type.String({
66
- maxLength: 512,
67
- description: "Optional cancellation reason recorded with the run evidence.",
68
- })),
69
- });
70
32
 
71
33
  export type ExternalRunsParams = Static<typeof externalRunsParameters> & {
72
34
  /** Legacy single-target selector; preserved for backwards-compatible programmatic/test callers. */
@@ -112,8 +74,16 @@ function notLive(message: string): ExpectedFlowError {
112
74
  return new ExpectedFlowError("run_not_live", message);
113
75
  }
114
76
 
115
- function assertRunId(runId: unknown): asserts runId is string {
116
- if (typeof runId !== "string" || !RUN_ID.test(runId)) throw invalid("Invalid run ID");
77
+ function assertRunId(runId: unknown, action: "inspect" | "wait" | "cancel" = "inspect"): asserts runId is string {
78
+ if (typeof runId === "string" && RUN_ID.test(runId)) return;
79
+ const example = action === "cancel"
80
+ ? EXTERNAL_RUNS_EXAMPLES.cancel
81
+ : action === "wait"
82
+ ? EXTERNAL_RUNS_EXAMPLES.wait
83
+ : EXTERNAL_RUNS_EXAMPLES.inspectSingle;
84
+ throw invalid(
85
+ `Invalid run ID for ${action}; pass a run_... or wf_... ID in runIds, e.g. ${JSON.stringify(example)}.`,
86
+ );
117
87
  }
118
88
 
119
89
  function assertOwned(entry: Pick<RegisteredRunEntry, "sessionId" | "project">, sessionId: string, project: string): void {
@@ -293,8 +263,16 @@ function decodeUnpersistedCursor(value: string, scope: string): UnpersistedBookm
293
263
  if (parsed?.kind !== "unpersisted-list") return undefined;
294
264
  const keys = Object.keys(parsed).sort().join(",");
295
265
  const valid = parsed.v === 1 && ((keys === "after,kind,scope,v" && typeof parsed.after === "string" && RUN_ID.test(parsed.after)) || (keys === "kind,persisted,scope,v" && parsed.persisted === true));
296
- if (!valid) throw cursorInvalid("Invalid list cursor");
297
- if (parsed.scope !== scope) throw cursorInvalid("Cursor belongs to a different list scope");
266
+ if (!valid) {
267
+ throw cursorInvalid(
268
+ `Invalid list cursor; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
269
+ );
270
+ }
271
+ if (parsed.scope !== scope) {
272
+ throw cursorInvalid(
273
+ `Cursor belongs to a different list scope for list; use cursor for runs (or workflowRunId children) and workflowCursor for unfiltered workflows, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
274
+ );
275
+ }
298
276
  return typeof parsed.after === "string" ? { after: parsed.after } : { persisted: true };
299
277
  }
300
278
 
@@ -317,7 +295,18 @@ async function pageUnpersistedFirst<L extends { runId: string }, D>(
317
295
  persisted: (limit: number, cursor: string | undefined) => Promise<{ items: D[]; nextCursor?: string }>,
318
296
  ): Promise<{ live: L[]; items: D[]; nextCursor?: string }> {
319
297
  const bookmark = cursor === undefined ? undefined : decodeUnpersistedCursor(cursor, scope);
320
- if (cursor !== undefined && bookmark === undefined) return { live: [], ...await persisted(pageSize, cursor) };
298
+ if (cursor !== undefined && bookmark === undefined) {
299
+ try {
300
+ return { live: [], ...await persisted(pageSize, cursor) };
301
+ } catch (error) {
302
+ if (error instanceof ExpectedFlowError && error.code === "cursor_invalid") {
303
+ throw cursorInvalid(
304
+ `${error.message} for list; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
305
+ );
306
+ }
307
+ throw error;
308
+ }
309
+ }
321
310
  if (bookmark && "persisted" in bookmark) return { live: [], ...await persisted(pageSize, undefined) };
322
311
  let start = 0;
323
312
  if (bookmark) {
@@ -465,12 +454,15 @@ function encodeBatchCursor(scope: string, runIds: string[], nextIndex: number):
465
454
  * invalidate the cursor.
466
455
  */
467
456
  function decodeBatchCursor(value: string, scope: string, runIds: string[]): number {
468
- if (!value || value.length > 8192) throw cursorInvalid();
457
+ const invalidBatch = () => cursorInvalid(
458
+ `Invalid inspect batch cursor; pass nextCursor with the identical runIds array, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
459
+ );
460
+ if (typeof value !== "string" || !value || value.length > 8192) throw invalidBatch();
469
461
  let parsed: Record<string, unknown>;
470
462
  try {
471
463
  parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
472
464
  } catch {
473
- throw cursorInvalid();
465
+ throw invalidBatch();
474
466
  }
475
467
  if (
476
468
  !parsed ||
@@ -480,14 +472,20 @@ function decodeBatchCursor(value: string, scope: string, runIds: string[]): numb
480
472
  !Array.isArray(parsed.runIds) ||
481
473
  !Number.isSafeInteger(parsed.nextIndex)
482
474
  ) {
483
- throw cursorInvalid();
475
+ throw invalidBatch();
476
+ }
477
+ if (parsed.scope !== scope) {
478
+ throw cursorInvalid(
479
+ `Cursor belongs to a different session or project for inspect; restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
480
+ );
484
481
  }
485
- if (parsed.scope !== scope) throw cursorInvalid("Cursor belongs to a different session or project");
486
482
  if (JSON.stringify(parsed.runIds) !== JSON.stringify(runIds)) {
487
- throw cursorInvalid("Batch cursor does not match the requested run IDs; repeat the identical runIds array to continue paging");
483
+ throw cursorInvalid(
484
+ `Batch cursor does not match the requested run IDs for inspect; repeat the identical runIds array to continue paging, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
485
+ );
488
486
  }
489
487
  const nextIndex = parsed.nextIndex as number;
490
- if (nextIndex < 0 || nextIndex > runIds.length) throw cursorInvalid();
488
+ if (nextIndex < 0 || nextIndex > runIds.length) throw invalidBatch();
491
489
  return nextIndex;
492
490
  }
493
491
 
@@ -504,15 +502,18 @@ function encodeProjectionCursor(runId: string, view: RunInspectionView, offset:
504
502
  }
505
503
 
506
504
  function decodeProjectionCursor(value: string, runId: string, view: RunInspectionView, text: string): number {
507
- if (!value || value.length > 4096) throw cursorInvalid();
505
+ const invalidInspect = () => cursorInvalid(
506
+ `Invalid inspect cursor; pass page.nextCursor with the same runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
507
+ );
508
+ if (typeof value !== "string" || !value || value.length > 4096) throw invalidInspect();
508
509
  let parsed: Record<string, unknown>;
509
510
  try {
510
511
  parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
511
512
  } catch {
512
- throw cursorInvalid();
513
+ throw invalidInspect();
513
514
  }
514
515
  const keys = parsed && typeof parsed === "object" ? Object.keys(parsed).sort().join(",") : "";
515
- if (keys !== "kind,offset,revision,runId,v,view" || parsed.v !== 1 || parsed.kind !== "projection-inspect" || parsed.runId !== runId || parsed.view !== view || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || typeof parsed.revision !== "string") throw cursorInvalid();
516
+ if (keys !== "kind,offset,revision,runId,v,view" || parsed.v !== 1 || parsed.kind !== "projection-inspect" || parsed.runId !== runId || parsed.view !== view || !Number.isSafeInteger(parsed.offset) || (parsed.offset as number) < 0 || typeof parsed.revision !== "string") throw invalidInspect();
516
517
  if (parsed.revision !== projectionRevision(text)) throw cursorStale();
517
518
  return parsed.offset as number;
518
519
  }
@@ -700,7 +701,7 @@ function renderExternalRunsCall(args: Record<string, unknown>, theme: Theme): Te
700
701
  : String(args.runId ?? "");
701
702
  detail = `inspect ${target} · ${typeof args.view === "string" ? args.view : "summary"}`;
702
703
  } else {
703
- detail = `list${typeof args.workflowRunId === "string" ? ` · workflow ${args.workflowRunId}` : ""}`;
704
+ detail = `list${typeof args.workflowRunId === "string" && args.workflowRunId.trim() !== "" ? ` · workflow ${args.workflowRunId.trim()}` : ""}`;
704
705
  }
705
706
  return new Text(`${theme.bold("External Runs")} ${theme.fg("dim", detail)}`, 0, 0);
706
707
  }
@@ -824,7 +825,9 @@ function normalizeInspectSelectors(params: ExternalRunsParams): ExternalRunsPara
824
825
  ? undefined
825
826
  : params.runIds;
826
827
  if (runId !== undefined && runIds !== undefined && runIds.length > 0) {
827
- throw invalid("inspect accepts either runId or runIds, not both");
828
+ throw invalid(
829
+ `inspect accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
830
+ );
828
831
  }
829
832
  if (runId === params.runId && runIds === params.runIds) return params;
830
833
  return { ...params, runId, runIds };
@@ -850,9 +853,17 @@ export function createExternalRunsTool(
850
853
  parameters: externalRunsParameters,
851
854
  outputSchema: externalRunsOutputSchema,
852
855
  async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx: ExtensionContext) {
853
- const action = params.action ?? "list";
856
+ const action = (params.action && (EXTERNAL_RUNS_ACTIONS as readonly string[]).includes(params.action)) ? params.action : "list";
854
857
  return await withContract({ tool: "external_runs", action, failureData: null }, async () => {
855
858
  if (params.action === "inspect") {
859
+ if (
860
+ params.limitBytes !== undefined &&
861
+ (typeof params.limitBytes !== "number" || !Number.isSafeInteger(params.limitBytes) || params.limitBytes < 4 || params.limitBytes > 65536)
862
+ ) {
863
+ throw invalid(
864
+ `Invalid limitBytes for inspect (must be an integer from 4 to 65536); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
865
+ );
866
+ }
856
867
  params = normalizeInspectSelectors(params);
857
868
  if (params.runId === undefined && params.runIds !== undefined && params.runIds.length === 1 && params.view !== undefined && params.view !== "summary") {
858
869
  params = { ...params, runId: params.runIds[0], runIds: undefined };
@@ -862,30 +873,76 @@ export function createExternalRunsTool(
862
873
  const runsDirectory = options.runsDirectory();
863
874
 
864
875
  if (params.action === "list") {
865
- if (params.workflowRunId !== undefined && !WORKFLOW_ID.test(params.workflowRunId)) throw invalid("Invalid workflow run ID");
876
+ if (
877
+ params.limit !== undefined &&
878
+ (typeof params.limit !== "number" || !Number.isSafeInteger(params.limit) || params.limit < 1 || params.limit > 100)
879
+ ) {
880
+ throw invalid(
881
+ `Invalid limit for list (must be an integer from 1 to 100); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
882
+ );
883
+ }
884
+ if (
885
+ params.workflowRunId !== undefined &&
886
+ (typeof params.workflowRunId !== "string" || (params.workflowRunId.trim() !== "" && !WORKFLOW_ID.test(params.workflowRunId.trim())))
887
+ ) {
888
+ throw invalid(
889
+ `Invalid workflow run ID for list; pass a wf_... ID (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowChildren)}) or omit workflowRunId (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}).`,
890
+ );
891
+ }
892
+ const workflowRunId = typeof params.workflowRunId === "string" && params.workflowRunId.trim() !== ""
893
+ ? params.workflowRunId.trim()
894
+ : undefined;
895
+ if (
896
+ workflowRunId !== undefined &&
897
+ params.workflowCursor !== undefined &&
898
+ (typeof params.workflowCursor !== "string" || params.workflowCursor.trim() !== "")
899
+ ) {
900
+ throw invalid(
901
+ `workflowCursor is inapplicable when filtering list by workflowRunId; page child runs with cursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or omit workflowRunId to page workflow roots (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}).`,
902
+ );
903
+ }
904
+ if (
905
+ workflowRunId === undefined &&
906
+ params.workflowCursor !== undefined &&
907
+ typeof params.workflowCursor !== "string"
908
+ ) {
909
+ throw cursorInvalid(
910
+ `Invalid workflow list cursor for list; pass nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
911
+ );
912
+ }
913
+ if (
914
+ params.cursor !== undefined &&
915
+ typeof params.cursor !== "string"
916
+ ) {
917
+ throw cursorInvalid(
918
+ `Invalid cursor for list; pass nextCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listRunsNextPage)}) or nextWorkflowCursor (${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.listWorkflowsNextPage)}), or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
919
+ );
920
+ }
921
+ const workflowCursor = typeof params.workflowCursor === "string" ? params.workflowCursor.trim() || undefined : undefined;
922
+ const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
866
923
  const workflowDir = getSessionWorkflowDir(ctx);
867
924
  // Live state overlays its persisted row on whichever page that row
868
925
  // falls, so a live run is never shown as interrupted or listed twice.
869
926
  // Live runs with no persisted row yet are paged first, inside the same
870
927
  // limit, before the persisted rows begin.
871
928
  const registered = new Map(options.registry.list(sessionId, project).map((entry) => [entry.runId, entry]));
872
- const runScope = listScope("runs", sessionId, project, params.workflowRunId);
873
- const page = params.workflowCursor && !params.cursor
929
+ const runScope = listScope("runs", sessionId, project, workflowRunId);
930
+ const page = workflowCursor && !cursor
874
931
  ? { live: [], items: [] }
875
- : await pageUnpersistedFirst(runScope, params.cursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 50))),
876
- [...registered.values()].filter((entry) => entry.kind === "agent" && (params.workflowRunId === undefined || entry.workflowRunId === params.workflowRunId)),
932
+ : await pageUnpersistedFirst(runScope, cursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 50))),
933
+ [...registered.values()].filter((entry) => entry.kind === "agent" && (workflowRunId === undefined || entry.workflowRunId === workflowRunId)),
877
934
  async (entry) => {
878
935
  const persisted = await getRunRecord(runsDirectory, entry.runId);
879
936
  return !persisted || persisted.parentSessionId !== sessionId || persisted.project !== project
880
- || (params.workflowRunId !== undefined && persisted.workflowRunId !== params.workflowRunId);
937
+ || (workflowRunId !== undefined && persisted.workflowRunId !== workflowRunId);
881
938
  },
882
- (limit, cursor) => listRunRecords({ runsDirectory, sessionId, project, workflowRunId: params.workflowRunId, limit, cursor }));
883
- const historical = params.workflowRunId
939
+ (limit, c) => listRunRecords({ runsDirectory, sessionId, project, workflowRunId, limit, cursor: c }));
940
+ const historical = workflowRunId
884
941
  ? { live: [], items: [] }
885
- : await pageUnpersistedFirst(listScope("workflows", sessionId, project), params.workflowCursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 100))),
942
+ : await pageUnpersistedFirst(listScope("workflows", sessionId, project), workflowCursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 100))),
886
943
  [...registered.values()].filter((entry) => entry.kind === "workflow"),
887
944
  async (entry) => !workflowDir || !await hasWorkflowJournal(workflowDir, entry.runId, project),
888
- async (limit, cursor) => workflowDir ? listWorkflowJournals(workflowDir, project, limit, cursor) : { items: [] });
945
+ async (limit, c) => workflowDir ? listWorkflowJournals(workflowDir, project, limit, c) : { items: [] });
889
946
  const workflowRows: Array<{ legacy: Record<string, unknown>; run: PublicRun }> = [
890
947
  ...historical.live.map((entry) => ({ legacy: liveSummary(entry), run: liveWorkflowRun(entry).run })),
891
948
  ...historical.items.map((journal) => {
@@ -915,14 +972,26 @@ export function createExternalRunsTool(
915
972
 
916
973
  if (params.action === "inspect" && params.runIds !== undefined) {
917
974
  // normalizeInspectSelectors already guarantees runId is unset here.
918
- if (params.view !== undefined && params.view !== "summary") throw invalid('Batch inspect (runIds) only supports view: "summary"');
975
+ if (params.view !== undefined && params.view !== "summary") {
976
+ throw invalid(
977
+ `Batch inspect (runIds) only supports view: "summary"; use ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)} for batch summary or ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)} for a single run.`,
978
+ );
979
+ }
919
980
  const runIds = [...new Set(params.runIds)];
920
981
  if (runIds.length === 0 || runIds.length > MAX_BATCH_INSPECT_TARGETS) {
921
- throw invalid(`inspect runIds requires 1-${MAX_BATCH_INSPECT_TARGETS} run IDs`);
982
+ throw invalid(
983
+ `inspect runIds requires 1-${MAX_BATCH_INSPECT_TARGETS} run IDs; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)} or ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
984
+ );
985
+ }
986
+ for (const runId of runIds) assertRunId(runId, "inspect");
987
+ if (params.cursor !== undefined && typeof params.cursor !== "string") {
988
+ throw cursorInvalid(
989
+ `Invalid batch cursor; pass nextCursor verbatim with the identical runIds array, or restart with ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectBatch)}.`,
990
+ );
922
991
  }
923
- for (const runId of runIds) assertRunId(runId);
992
+ const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
924
993
  const scope = batchCursorScope(sessionId, project);
925
- const startIndex = params.cursor ? decodeBatchCursor(params.cursor, scope, runIds) : 0;
994
+ const startIndex = cursor ? decodeBatchCursor(cursor, scope, runIds) : 0;
926
995
  // Resolve (and thereby validate ownership of) every requested target
927
996
  // before returning any page — an invalid or cross-session/project
928
997
  // target anywhere in the set fails the whole request, not just the
@@ -960,9 +1029,20 @@ export function createExternalRunsTool(
960
1029
  }
961
1030
 
962
1031
  if (params.action === "inspect") {
963
- assertRunId(params.runId);
1032
+ if (params.view !== undefined && !SINGLE_INSPECT_VIEWS.includes(params.view as any)) {
1033
+ throw invalid(
1034
+ `Invalid view ${JSON.stringify(params.view)} for inspect; choose ${SINGLE_INSPECT_VIEWS.join(", ")}, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.inspectSingle)}.`,
1035
+ );
1036
+ }
1037
+ assertRunId(params.runId, "inspect");
964
1038
  const runId = params.runId;
965
1039
  const view = params.view ?? "summary";
1040
+ if (params.cursor !== undefined && typeof params.cursor !== "string") {
1041
+ throw cursorInvalid(
1042
+ `Invalid inspect cursor; pass page.nextCursor with the same runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
1043
+ );
1044
+ }
1045
+ const cursor = typeof params.cursor === "string" ? params.cursor.trim() || undefined : undefined;
966
1046
  const entry = options.registry.get(runId);
967
1047
  if (entry) assertOwned(entry, sessionId, project);
968
1048
  const single = (text: string, encoding: "json" | "text", details: Record<string, unknown>, extra: { finalAvailable?: boolean; outputStatus?: string } = {}, contentText = text) => {
@@ -990,11 +1070,11 @@ export function createExternalRunsTool(
990
1070
  // layer's job (src/external-command.ts's showPages), not this tool's.
991
1071
  if (!finalAvailable) {
992
1072
  const status = entry ? (entry.state === "running" ? "running" : entry.outcome?.status) : historical!.status;
993
- const page = projectionPage(runId, view, "", params.cursor, params.limitBytes);
1073
+ const page = projectionPage(runId, view, "", cursor, params.limitBytes);
994
1074
  return single(page.text, "json", { runId, view, text: page.text, finalAvailable: false, status: status ?? "running", nextCursor: page.nextCursor }, { finalAvailable: false });
995
1075
  }
996
1076
  const text = JSON.stringify(redactSecrets({ runId, result: workflowResult, finalAvailable: true }));
997
- const page = projectionPage(runId, view, text, params.cursor, params.limitBytes);
1077
+ const page = projectionPage(runId, view, text, cursor, params.limitBytes);
998
1078
  return single(page.text, "json", { runId, view, text: page.text, finalAvailable: true, nextCursor: page.nextCursor }, { finalAvailable: true });
999
1079
  }
1000
1080
  const source = view === "launch"
@@ -1008,7 +1088,7 @@ export function createExternalRunsTool(
1008
1088
  ? { runId, result: entry ? entry.outcome?.result : historical?.result }
1009
1089
  : { runId, status: entry?.outcome?.status ?? historical?.status ?? "running", error: entry?.outcome?.error ?? historical?.error };
1010
1090
  const text = JSON.stringify(redactSecrets(source));
1011
- const page = projectionPage(runId, view, text, params.cursor, params.limitBytes);
1091
+ const page = projectionPage(runId, view, text, cursor, params.limitBytes);
1012
1092
  return single(page.text, "json", { runId, view, text: page.text, nextCursor: page.nextCursor });
1013
1093
  }
1014
1094
  // A wf_... ID that resolved to neither a live nor a historical
@@ -1020,25 +1100,35 @@ export function createExternalRunsTool(
1020
1100
  if (!entry) assertOwnedRecord(durable, sessionId, project);
1021
1101
  if (view === "summary") {
1022
1102
  const source = entry ? redactSecrets(projectLiveAgent(entry)) : historicalAgent(durable!);
1023
- const page = projectionPage(runId, view, JSON.stringify(source), params.cursor, params.limitBytes);
1103
+ const page = projectionPage(runId, view, JSON.stringify(source), cursor, params.limitBytes);
1024
1104
  return single(page.text, "json", { runId, view, text: page.text, nextCursor: page.nextCursor });
1025
1105
  }
1026
1106
  // A settled successful result in the registry is the verified final
1027
1107
  // answer, so a final reference stays followable even when evidence
1028
1108
  // was never persisted.
1029
- if (view === "final" && entry?.kind === "agent" && entry.outcome?.status === "done" && entry.outcome.result !== undefined && (!params.cursor || isProjectionCursor(params.cursor))) {
1030
- const page = projectionPage(runId, view, liveAgentOutput(entry) ?? "", params.cursor, params.limitBytes);
1109
+ if (view === "final" && entry?.kind === "agent" && entry.outcome?.status === "done" && entry.outcome.result !== undefined && (!cursor || isProjectionCursor(cursor))) {
1110
+ const page = projectionPage(runId, view, liveAgentOutput(entry) ?? "", cursor, params.limitBytes);
1031
1111
  return single(page.text, "text", { runId, view, text: page.text, finalAvailable: true, outputStatus: "final", nextCursor: page.nextCursor }, { finalAvailable: true, outputStatus: "final" });
1032
1112
  }
1033
- if (view === "output" && entry?.kind === "agent" && (!params.cursor || isProjectionCursor(params.cursor))) {
1113
+ if (view === "output" && entry?.kind === "agent" && (!cursor || isProjectionCursor(cursor))) {
1034
1114
  const text = liveAgentOutput(entry);
1035
1115
  if (text !== undefined) {
1036
- const page = projectionPage(runId, view, text, params.cursor, params.limitBytes);
1116
+ const page = projectionPage(runId, view, text, cursor, params.limitBytes);
1037
1117
  const outputStatus = entry.state === "running" ? "preliminary" : entry.outcome?.outcome === "succeeded" ? "final" : "interrupted";
1038
1118
  return single(page.text, "text", { runId, view, text: page.text, outputStatus, nextCursor: page.nextCursor }, { outputStatus });
1039
1119
  }
1040
1120
  }
1041
- const page = await inspectRun({ runsDirectory, runId, view, limitBytes: params.limitBytes, cursor: params.cursor, live: entry?.state === "running" });
1121
+ let page: Awaited<ReturnType<typeof inspectRun>>;
1122
+ try {
1123
+ page = await inspectRun({ runsDirectory, runId, view, limitBytes: params.limitBytes, cursor, live: entry?.state === "running" });
1124
+ } catch (error) {
1125
+ if (error instanceof ExpectedFlowError && error.code === "cursor_invalid") {
1126
+ throw cursorInvalid(
1127
+ `${error.message} for inspect; pass nextCursor with matching runId and view, or restart with ${JSON.stringify({ action: "inspect", runIds: [runId], view })}.`,
1128
+ );
1129
+ }
1130
+ throw error;
1131
+ }
1042
1132
  const text = pageItemsText(page);
1043
1133
  // view: "final" stays a clean, narration-free canonical-answer
1044
1134
  // surface — a bounded empty page (finalAvailable:false) when
@@ -1050,16 +1140,25 @@ export function createExternalRunsTool(
1050
1140
  }
1051
1141
 
1052
1142
  if (params.action === "cancel") {
1143
+ if (params.reason !== undefined && (typeof params.reason !== "string" || params.reason.length > 512)) {
1144
+ throw invalid(
1145
+ `Invalid reason for cancel (must be a string up to 512 characters); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancelWithReason)}.`,
1146
+ );
1147
+ }
1053
1148
  const rawRunIds = params.runIds;
1054
1149
  if (rawRunIds?.length && params.runId?.trim()) {
1055
- throw invalid("cancel accepts either runId or runIds, not both");
1150
+ throw invalid(
1151
+ `cancel accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancel)}.`,
1152
+ );
1056
1153
  }
1057
1154
  if (rawRunIds && rawRunIds.length > 1) {
1058
- throw invalid("cancel targets one run at a time");
1155
+ throw invalid(
1156
+ `cancel targets one run at a time; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.cancel)}.`,
1157
+ );
1059
1158
  }
1060
1159
  const targetRunId = (rawRunIds && rawRunIds.length === 1 ? rawRunIds[0] : undefined)
1061
1160
  ?? (typeof params.runId === "string" && params.runId.trim() !== "" ? params.runId.trim() : undefined);
1062
- assertRunId(targetRunId);
1161
+ assertRunId(targetRunId, "cancel");
1063
1162
  const cancelled = (status: "requested" | "terminal") => ({
1064
1163
  ...result(status === "requested" ? `Cancellation requested for ${targetRunId}.` : `${targetRunId} is already terminal.`, { runId: targetRunId, status }),
1065
1164
  data: { runId: targetRunId, status },
@@ -1067,7 +1166,8 @@ export function createExternalRunsTool(
1067
1166
  const entry = options.registry.get(targetRunId);
1068
1167
  if (entry) {
1069
1168
  assertOwned(entry, sessionId, project);
1070
- const status = options.registry.cancel(targetRunId, params.reason ?? "cancelled by external_runs");
1169
+ const reason = params.reason?.trim() ? params.reason.trim() : "cancelled by external_runs";
1170
+ const status = options.registry.cancel(targetRunId, reason);
1071
1171
  // "unknown" means the entry left the registry between get and cancel.
1072
1172
  if (status === "unknown") throw unavailable();
1073
1173
  return cancelled(status);
@@ -1086,9 +1186,36 @@ export function createExternalRunsTool(
1086
1186
  throw notLive("Run is no longer live in this session; cancellation cannot be confirmed");
1087
1187
  }
1088
1188
 
1089
- const runIds = [...new Set(params.runIds ?? (params.runId ? [params.runId] : []))];
1090
- if (runIds.length === 0 || runIds.length > MAX_TARGETS) throw invalid(`wait requires 1-${MAX_TARGETS} run IDs`);
1091
- for (const runId of runIds) assertRunId(runId);
1189
+ if (params.action === "wait") {
1190
+ if (
1191
+ params.limitBytes !== undefined &&
1192
+ (typeof params.limitBytes !== "number" || !Number.isSafeInteger(params.limitBytes) || params.limitBytes < 4 || params.limitBytes > 65536)
1193
+ ) {
1194
+ throw invalid(
1195
+ `Invalid limitBytes for wait (must be an integer from 4 to 65536); e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
1196
+ );
1197
+ }
1198
+ if (params.mode !== undefined && !WAIT_MODES.includes(params.mode as any)) {
1199
+ throw invalid(
1200
+ `Invalid mode ${JSON.stringify(params.mode)} for wait; choose ${WAIT_MODES.join(" or ")}, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
1201
+ );
1202
+ }
1203
+ const rawRunIds = params.runIds;
1204
+ if (rawRunIds?.length && params.runId?.trim()) {
1205
+ throw invalid(
1206
+ `wait accepts either runId or runIds, not both; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
1207
+ );
1208
+ }
1209
+ const rawWaitRunIds = (rawRunIds && rawRunIds.length > 0)
1210
+ ? rawRunIds
1211
+ : (params.runId?.trim() ? [params.runId.trim()] : []);
1212
+ const runIds = [...new Set(rawWaitRunIds)];
1213
+ if (runIds.length === 0 || runIds.length > MAX_WAIT_TARGETS) {
1214
+ throw invalid(
1215
+ `wait requires 1-${MAX_WAIT_TARGETS} run IDs; e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.wait)}.`,
1216
+ );
1217
+ }
1218
+ for (const runId of runIds) assertRunId(runId, "wait");
1092
1219
  const known: WaitTarget[] = await Promise.all(runIds.map(async (runId): Promise<WaitTarget> => {
1093
1220
  const entry = options.registry.get(runId);
1094
1221
  if (entry) {
@@ -1201,6 +1328,13 @@ export function createExternalRunsTool(
1201
1328
  data: { mode, completed, pending: finalPending },
1202
1329
  warnings: [...warnings],
1203
1330
  };
1331
+ }
1332
+
1333
+ throw invalid(
1334
+ params.action === undefined
1335
+ ? `Missing required action; choose list, inspect, wait, or cancel, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`
1336
+ : `Invalid action ${JSON.stringify(params.action)}; choose list, inspect, wait, or cancel, e.g. ${JSON.stringify(EXTERNAL_RUNS_EXAMPLES.list)}.`,
1337
+ );
1204
1338
  });
1205
1339
  },
1206
1340
  renderCall(args, theme) {
@@ -17,6 +17,7 @@ import { resolve, join } from "node:path";
17
17
  import { resolveExecutionProfile } from './execution-config.ts';
18
18
  import { mkdtemp, rm } from 'node:fs/promises';
19
19
  import { tmpdir } from 'node:os';
20
+ import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
20
21
  import { Text } from "@earendil-works/pi-tui";
21
22
  import { Type, type Static } from "typebox";
22
23
  import {
@@ -51,7 +52,7 @@ import { registerProfileCreator, startRoleInterview, startHarnessInterview } fro
51
52
  import { registerExternalCommand } from "./external-command.ts";
52
53
  import { DEFAULT_EXTERNAL_SETTINGS, loadExternalSettings, resolveCtxDefaultHarness, resolveExternalSettings, renderDefaultHarness } from "./settings.ts";
53
54
  import { EXTERNAL_HARNESSES } from "./types.ts";
54
- import { EXTERNAL_DELEGATE_TOOL } from "./tool-names.ts";
55
+ import { EXTERNAL_DELEGATE_TOOL, EXTERNAL_WORKFLOW_TOOL } from "./tool-names.ts";
55
56
  import type {
56
57
  PermissionTier,
57
58
  SubagentBackend,
@@ -257,6 +258,45 @@ function isSubagentProgressNode(value: unknown): value is SubagentProgressNode {
257
258
  );
258
259
  }
259
260
 
261
+ // Launcher names a transcript saved before the #85 rename declares, and their replacements.
262
+ const RENAMED_LAUNCHERS: ReadonlyArray<readonly [legacy: string, current: string]> = [
263
+ ["Agent", EXTERNAL_DELEGATE_TOOL],
264
+ ["workflow", EXTERNAL_WORKFLOW_TOOL],
265
+ ];
266
+
267
+ // Launchers the user kept off on the command line. Pi applies `--tools` modifiers
268
+ // to the restored loadout but does not expose them to extensions, and a `-name`
269
+ // modifier cannot match the legacy name it replaces. `--no-tools` turns
270
+ // extension tools off by default. SDK `tools` options are not visible here.
271
+ function cliExcludedLaunchers(argv: readonly string[]): Set<string> {
272
+ if (argv.includes("--no-tools") || argv.includes("-nt")) return new Set(RENAMED_LAUNCHERS.map(([, current]) => current));
273
+ let tools: string[] = [];
274
+ argv.forEach((arg, index) => {
275
+ if ((arg === "--tools" || arg === "-t") && index + 1 < argv.length) {
276
+ tools = argv[index + 1].split(",").map((name) => name.trim());
277
+ }
278
+ });
279
+ return new Set(tools.filter((name) => name.startsWith("-")).map((name) => name.slice(1)));
280
+ }
281
+
282
+ // Pi restores a resumed or tree-navigated session's tool loadout from its
283
+ // transcript. A legacy name no longer registers, so its replacement stays off.
284
+ // Activate the replacement for each legacy name the restored loadout declares,
285
+ // unless the loadout also declares the replacement (it was then turned off on
286
+ // purpose) or the command line excludes it. setActiveTools ignores a tool that
287
+ // is not registered or not allowed.
288
+ function restoreRenamedLaunchers(pi: ExtensionAPI, ctx: ExtensionContext, argv: readonly string[] = process.argv): void {
289
+ const declared = new Set(
290
+ (getCurrentSystemMessage(ctx.sessionManager.buildSessionProjection().messages)?.toolsAdded ?? []).map((tool) => tool.name),
291
+ );
292
+ const active = pi.getActiveTools();
293
+ const excluded = cliExcludedLaunchers(argv);
294
+ const restored = RENAMED_LAUNCHERS
295
+ .filter(([legacy, current]) => declared.has(legacy) && !declared.has(current) && !active.includes(current) && !excluded.has(current))
296
+ .map(([, current]) => current);
297
+ if (restored.length > 0) pi.setActiveTools([...active, ...restored]);
298
+ }
299
+
260
300
  function createUsageStatusState(): SubagentUsageStatusState {
261
301
  return {
262
302
  calls: new Map(),
@@ -1019,7 +1059,9 @@ export function createSubagentExtension(options: SubagentExtensionOptions = {}):
1019
1059
  if (activeSessionId) scheduleWakeFlush(activeSessionId);
1020
1060
  });
1021
1061
 
1062
+ pi.on("session_tree", (_event, ctx) => restoreRenamedLaunchers(pi, ctx));
1022
1063
  pi.on("session_start", (_event, ctx) => {
1064
+ restoreRenamedLaunchers(pi, ctx);
1023
1065
  const sessionId = ctx.sessionManager.getSessionId();
1024
1066
  rememberSessionContext(ctx, sessionId);
1025
1067
  inAgentTurn = false;
package/src/prompts.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { EXTERNAL_RUNS_EXAMPLES, formatExternalRunsActionGuidance } from "./contract/runs.ts";
1
2
  import type { SavedWorkflow } from "./workflow/registry.ts";
2
3
  import { EXTERNAL_HARNESSES, type PermissionTier, type SubagentProfile } from "./types.ts";
3
4
  import { externalProfileRole, externalRoleAvailability } from "./profiles.ts";
@@ -12,7 +13,7 @@ export const EXTERNAL_HELP_PROMPT_SNIPPET =
12
13
  "Show the delegation playbook, role details, permission behavior, or workflow guidance on demand.";
13
14
 
14
15
  export const EXTERNAL_RUNS_PROMPT_SNIPPET =
15
- "List, inspect (single or batch summary via runIds, up to 20), wait for, or cancel session-owned external runs; follow cursors for complete output. view: final returns only a verified terminal answer, empty until one exists.";
16
+ "Supervise session-owned external runs (supply only fields for the chosen action): list (optional workflowRunId, limit, cursor, workflowCursor), inspect (runIds: 1 ID with any view, or 2-20 IDs for summary batch), wait (runIds 1-100, optional mode/limitBytes), or cancel (runIds: 1 ID, optional reason). view: final returns only a verified terminal answer, empty until one exists.";
16
17
 
17
18
  // Only elide the "(harness only)" suffix when every configured harness
18
19
  // carries the role; a role available on all configured CLIs but no pi harness
@@ -143,12 +144,8 @@ export const USAGE_BACKGROUND_AGENT: UsageAgentExample = {
143
144
  background: true,
144
145
  };
145
146
 
146
- /** Single-id batch inspect. The executor accepts runIds with view final for one id. */
147
- export const USAGE_RUNS_FINAL = {
148
- action: "inspect" as const,
149
- runIds: ["run_example"],
150
- view: "final" as const,
151
- };
147
+ /** Single-id inspect with final view, shared with the authoritative external_runs contract examples. */
148
+ export const USAGE_RUNS_FINAL = EXTERNAL_RUNS_EXAMPLES.inspectSingle;
152
149
 
153
150
  /** Continues a CLI child that stored a session id. Named pi-* harnesses cannot resume. */
154
151
  export const USAGE_RESUME_AGENT: UsageAgentExample = {
@@ -219,6 +216,8 @@ Choose foreground by default when you need the result next, even for a long or p
219
216
 
220
217
  Unless the user deferred collection, after independent work use external_runs wait if settlement is not already known. When the user later asks about a deferred run, inspect its runId first and wait only if it is still running. Check ok for the operation, then each completed run's state.outcome and pending IDs; a failed workflow can return wait early even in mode all. Use successful output when delivery is inline (null/false/0 are valid); otherwise inspect view final, following cursor pages. For failures inspect diagnostics or output; final is empty without verified success. A successful workflow may still have children.failed when its script caught child errors; read the reports. Interrupting the original foreground call cancels its work. Interrupting wait only stops waiting; cancel explicitly to stop background work. Cancelling a workflow stops active children; cancelling one child throws a catchable child error. Both modes share permissions, budget enforcement, and concurrency limits. A failed or aborted run stays failed. Retry it, or switch model or harness, only when the user authorized that step. agy may make one disclosed infrastructure retry; that exception is not yours to extend.
221
218
 
219
+ ${formatExternalRunsActionGuidance()}
220
+
222
221
  One external_delegate call. Permission stays omitted; the prompt carries the no-edit intent:
223
222
  ${jsonBlock(USAGE_ONE_AGENT)}
224
223
 
@@ -48,8 +48,13 @@ export interface LoadedWorkflowJournal {
48
48
 
49
49
  /** A journal that exists but cannot be loaded; `project` is set when its own run_start line names one. */
50
50
  export class WorkflowJournalReadError extends ExpectedFlowError {
51
- constructor(message: string, readonly project: string | undefined, readonly integrity: "damaged" | "unknown") {
51
+ readonly project: string | undefined;
52
+ readonly integrity: "damaged" | "unknown";
53
+
54
+ constructor(message: string, project: string | undefined, integrity: "damaged" | "unknown") {
52
55
  super("run_unavailable", message);
56
+ this.project = project;
57
+ this.integrity = integrity;
53
58
  this.name = "WorkflowJournalReadError";
54
59
  }
55
60
  }
@@ -243,7 +248,7 @@ function workflowListCursor(name: string): string {
243
248
 
244
249
  function workflowListStart(names: string[], cursor?: string): number {
245
250
  if (!cursor) return 0;
246
- if (cursor.length > 4_096) throw new ExpectedFlowError("cursor_invalid", "Invalid workflow list cursor");
251
+ if (typeof cursor !== "string" || cursor.length > 4_096) throw new ExpectedFlowError("cursor_invalid", "Invalid workflow list cursor");
247
252
  try {
248
253
  const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Record<string, unknown>;
249
254
  if (Object.keys(parsed).sort().join(",") !== "kind,name,v" || parsed.v !== 1 || parsed.kind !== "workflow-list" || typeof parsed.name !== "string") throw new Error();
@@ -260,8 +265,8 @@ export async function listWorkflowJournals(dir: string, project: string, limit =
260
265
  try {
261
266
  names = (await readdir(dir)).filter((name) => /^run-wf_[A-Za-z0-9_-]{1,128}\.jsonl$/.test(name)).sort().reverse();
262
267
  } catch (error) {
263
- if (isNotFound(error)) return { items: [] };
264
- throw error;
268
+ if (!isNotFound(error)) throw error;
269
+ names = [];
265
270
  }
266
271
  const start = workflowListStart(names, cursor);
267
272
  const pageLimit = Math.max(1, Math.min(100, limit));