@tt-a1i/openpi 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ export interface InvocationIdentity {
2
+ readonly runId: string;
3
+ readonly callIndex: number;
4
+ }
5
+
6
+ export type InvocationIntentState = "requested";
7
+ export type InvocationAdmissionState =
8
+ "pending" | "claimed" | "replayed" | "rejected";
9
+ export type InvocationExecutionState =
10
+ "pending" | "running" | "settled" | "uncertain";
11
+ export type InvocationOutcome = "success" | "error" | "uncertain";
12
+
13
+ /**
14
+ * Three-plane durable lifecycle for one workflow call.
15
+ *
16
+ * Intent says what the script requested, admission says whether the host
17
+ * accepted or replayed it, and execution says what the child runtime actually
18
+ * proved. Keeping them separate prevents a persisted `running` label from
19
+ * pretending that an interrupted side effect is known to have failed.
20
+ */
21
+ export interface InvocationRecord {
22
+ readonly identity: InvocationIdentity;
23
+ readonly intentState: InvocationIntentState;
24
+ readonly admissionState: InvocationAdmissionState;
25
+ readonly executionState: InvocationExecutionState;
26
+ readonly outcome?: InvocationOutcome;
27
+ readonly requestedAt: number;
28
+ readonly claimedAt?: number;
29
+ readonly runningAt?: number;
30
+ readonly terminalAt?: number;
31
+ }
32
+
33
+ export type InvocationTransition =
34
+ | { readonly status: "claimed"; readonly at: number }
35
+ | { readonly status: "running"; readonly at: number }
36
+ | {
37
+ readonly status: "settled";
38
+ readonly outcome: "success" | "error";
39
+ readonly at: number;
40
+ }
41
+ | { readonly status: "replayed"; readonly at: number }
42
+ | { readonly status: "rejected"; readonly at: number };
43
+
44
+ const ADMISSION_STATES: readonly InvocationAdmissionState[] = [
45
+ "pending",
46
+ "claimed",
47
+ "replayed",
48
+ "rejected",
49
+ ];
50
+ const EXECUTION_STATES: readonly InvocationExecutionState[] = [
51
+ "pending",
52
+ "running",
53
+ "settled",
54
+ "uncertain",
55
+ ];
56
+
57
+ function assertIdentity(identity: InvocationIdentity) {
58
+ if (typeof identity.runId !== "string" || identity.runId.trim() === "") {
59
+ throw new Error("runId must be a nonblank string");
60
+ }
61
+ if (!Number.isSafeInteger(identity.callIndex) || identity.callIndex <= 0) {
62
+ throw new Error("callIndex must be a positive safe integer");
63
+ }
64
+ }
65
+
66
+ function assertFinite(value: number | undefined, name: string) {
67
+ if (typeof value !== "number" || !Number.isFinite(value)) {
68
+ throw new Error(`${name} must be finite`);
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function immutableSnapshot(record: InvocationRecord) {
74
+ return Object.freeze({
75
+ ...record,
76
+ identity: Object.freeze({ ...record.identity }),
77
+ });
78
+ }
79
+
80
+ function lifecycleLabel(record: InvocationRecord) {
81
+ if (record.admissionState === "replayed") return "replayed";
82
+ if (record.admissionState === "rejected") return "rejected";
83
+ if (
84
+ record.executionState === "settled" ||
85
+ record.executionState === "uncertain"
86
+ ) {
87
+ return "settled";
88
+ }
89
+ if (record.executionState === "running") return "running";
90
+ if (record.admissionState === "claimed") return "claimed";
91
+ return "requested";
92
+ }
93
+
94
+ function assertRecord(record: InvocationRecord) {
95
+ assertIdentity(record.identity);
96
+ assertFinite(record.requestedAt, "requestedAt");
97
+ if (record.intentState !== "requested") {
98
+ throw new Error("Invalid invocation intent state");
99
+ }
100
+ if (!ADMISSION_STATES.includes(record.admissionState)) {
101
+ throw new Error("Invalid invocation admission state");
102
+ }
103
+ if (!EXECUTION_STATES.includes(record.executionState)) {
104
+ throw new Error("Invalid invocation execution state");
105
+ }
106
+ if (record.admissionState === "claimed") {
107
+ assertFinite(record.claimedAt, "claimedAt");
108
+ }
109
+ if (record.executionState === "running") {
110
+ assertFinite(record.runningAt, "runningAt");
111
+ }
112
+ if (
113
+ record.executionState === "settled" ||
114
+ record.executionState === "uncertain"
115
+ ) {
116
+ assertFinite(record.terminalAt, "terminalAt");
117
+ }
118
+ if (
119
+ record.admissionState === "replayed" &&
120
+ (record.executionState !== "settled" || record.outcome !== "success")
121
+ ) {
122
+ throw new Error("Invalid replayed invocation state");
123
+ }
124
+ if (
125
+ record.admissionState === "rejected" &&
126
+ (record.executionState !== "settled" || record.outcome !== "error")
127
+ ) {
128
+ throw new Error("Invalid rejected invocation state");
129
+ }
130
+ if (
131
+ record.admissionState === "pending" &&
132
+ record.executionState !== "pending" &&
133
+ record.executionState !== "uncertain"
134
+ ) {
135
+ throw new Error("Invalid pending invocation state");
136
+ }
137
+ if (
138
+ record.executionState === "settled" &&
139
+ record.outcome !== "success" &&
140
+ record.outcome !== "error"
141
+ ) {
142
+ throw new Error("Settled invocation requires a success or error outcome");
143
+ }
144
+ if (record.executionState === "uncertain" && record.outcome !== "uncertain") {
145
+ throw new Error("Uncertain invocation requires an uncertain outcome");
146
+ }
147
+ if (
148
+ (record.executionState === "pending" ||
149
+ record.executionState === "running") &&
150
+ record.outcome !== undefined
151
+ ) {
152
+ throw new Error("Nonterminal invocation cannot have an outcome");
153
+ }
154
+ if (
155
+ record.executionState !== "settled" &&
156
+ record.executionState !== "uncertain" &&
157
+ record.terminalAt !== undefined
158
+ ) {
159
+ throw new Error("Nonterminal invocation cannot have terminalAt");
160
+ }
161
+ if (record.admissionState !== "claimed" && record.claimedAt !== undefined) {
162
+ throw new Error("Only a claimed invocation can have claimedAt");
163
+ }
164
+ if (
165
+ record.runningAt !== undefined &&
166
+ (record.admissionState !== "claimed" ||
167
+ (record.executionState !== "running" &&
168
+ record.executionState !== "settled" &&
169
+ record.executionState !== "uncertain"))
170
+ ) {
171
+ throw new Error("Invalid runningAt for invocation state");
172
+ }
173
+ if (
174
+ record.admissionState === "claimed" &&
175
+ record.executionState === "settled" &&
176
+ record.runningAt === undefined
177
+ ) {
178
+ throw new Error("A normally settled invocation requires runningAt");
179
+ }
180
+
181
+ const timestamps = [
182
+ ["requestedAt", record.requestedAt],
183
+ ["claimedAt", record.claimedAt],
184
+ ["runningAt", record.runningAt],
185
+ ["terminalAt", record.terminalAt],
186
+ ] as const;
187
+ let previous = record.requestedAt;
188
+ for (const [name, timestamp] of timestamps.slice(1)) {
189
+ if (timestamp === undefined) continue;
190
+ assertFinite(timestamp, name);
191
+ if (timestamp < previous) {
192
+ throw new Error(`${name} cannot precede an earlier lifecycle timestamp`);
193
+ }
194
+ previous = timestamp;
195
+ }
196
+ }
197
+
198
+ function isRecord(value: unknown): value is Record<string, unknown> {
199
+ return typeof value === "object" && value !== null && !Array.isArray(value);
200
+ }
201
+
202
+ /** Decode untrusted persisted data without inventing missing lifecycle facts. */
203
+ export function decodeInvocationRecord(value: unknown) {
204
+ if (!isRecord(value) || !isRecord(value.identity)) return undefined;
205
+ const candidate = value as unknown as InvocationRecord;
206
+ try {
207
+ assertRecord(candidate);
208
+ return immutableSnapshot(candidate);
209
+ } catch {
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ function currentTransitionTime(record: InvocationRecord) {
215
+ if (record.terminalAt !== undefined) {
216
+ return {
217
+ name: "terminalAt",
218
+ at: assertFinite(record.terminalAt, "terminalAt"),
219
+ };
220
+ }
221
+ if (record.runningAt !== undefined) {
222
+ return {
223
+ name: "runningAt",
224
+ at: assertFinite(record.runningAt, "runningAt"),
225
+ };
226
+ }
227
+ if (record.claimedAt !== undefined) {
228
+ return {
229
+ name: "claimedAt",
230
+ at: assertFinite(record.claimedAt, "claimedAt"),
231
+ };
232
+ }
233
+ return { name: "requestedAt", at: record.requestedAt };
234
+ }
235
+
236
+ function assertChronology(record: InvocationRecord, at: number) {
237
+ assertFinite(at, "at");
238
+ const current = currentTransitionTime(record);
239
+ if (at < current.at) {
240
+ throw new Error(`Transition time cannot precede ${current.name}`);
241
+ }
242
+ }
243
+
244
+ function isLegalTransition(
245
+ record: InvocationRecord,
246
+ next: InvocationTransition["status"],
247
+ ) {
248
+ if (
249
+ record.admissionState === "pending" &&
250
+ record.executionState === "pending"
251
+ ) {
252
+ return next === "claimed" || next === "replayed" || next === "rejected";
253
+ }
254
+ if (
255
+ record.admissionState === "claimed" &&
256
+ record.executionState === "pending"
257
+ ) {
258
+ return next === "running";
259
+ }
260
+ if (
261
+ record.admissionState === "claimed" &&
262
+ record.executionState === "running"
263
+ ) {
264
+ return next === "settled";
265
+ }
266
+ return false;
267
+ }
268
+
269
+ /** Structured identity avoids ambiguous string concatenation of run and call. */
270
+ export function createInvocationIdentity(runId: string, callIndex: number) {
271
+ const identity = { runId, callIndex };
272
+ assertIdentity(identity);
273
+ return Object.freeze(identity);
274
+ }
275
+
276
+ /** Start a call record without consulting a clock inside the ledger. */
277
+ export function requestInvocation(identity: InvocationIdentity, at: number) {
278
+ assertIdentity(identity);
279
+ assertFinite(at, "at");
280
+ return immutableSnapshot({
281
+ identity,
282
+ intentState: "requested",
283
+ admissionState: "pending",
284
+ executionState: "pending",
285
+ requestedAt: at,
286
+ });
287
+ }
288
+
289
+ /** Apply one normal lifecycle or replay/rejection transition. */
290
+ export function transitionInvocation(
291
+ record: InvocationRecord,
292
+ transition: InvocationTransition,
293
+ ) {
294
+ assertRecord(record);
295
+ if (!isLegalTransition(record, transition.status)) {
296
+ throw new Error(
297
+ `Illegal invocation transition: ${lifecycleLabel(record)} -> ${transition.status}`,
298
+ );
299
+ }
300
+ assertChronology(record, transition.at);
301
+
302
+ switch (transition.status) {
303
+ case "claimed":
304
+ return immutableSnapshot({
305
+ ...record,
306
+ admissionState: "claimed",
307
+ claimedAt: transition.at,
308
+ });
309
+ case "running":
310
+ return immutableSnapshot({
311
+ ...record,
312
+ executionState: "running",
313
+ runningAt: transition.at,
314
+ });
315
+ case "settled":
316
+ return immutableSnapshot({
317
+ ...record,
318
+ executionState: "settled",
319
+ outcome: transition.outcome,
320
+ terminalAt: transition.at,
321
+ });
322
+ case "replayed":
323
+ return immutableSnapshot({
324
+ ...record,
325
+ admissionState: "replayed",
326
+ executionState: "settled",
327
+ outcome: "success",
328
+ terminalAt: transition.at,
329
+ });
330
+ case "rejected":
331
+ return immutableSnapshot({
332
+ ...record,
333
+ admissionState: "rejected",
334
+ executionState: "settled",
335
+ outcome: "error",
336
+ terminalAt: transition.at,
337
+ });
338
+ }
339
+ }
340
+
341
+ /** Interrupt recovery is conservative: execution may have happened. */
342
+ export function classifyInterruptedInvocation(
343
+ record: InvocationRecord,
344
+ at: number,
345
+ ) {
346
+ assertRecord(record);
347
+ if (
348
+ record.executionState === "settled" ||
349
+ record.executionState === "uncertain"
350
+ ) {
351
+ throw new Error(
352
+ `Cannot classify terminal invocation ${lifecycleLabel(record)} as uncertain`,
353
+ );
354
+ }
355
+ assertChronology(record, at);
356
+ return immutableSnapshot({
357
+ ...record,
358
+ executionState: "uncertain",
359
+ outcome: "uncertain",
360
+ terminalAt: at,
361
+ });
362
+ }
@@ -12,6 +12,12 @@ import { formatContextUtilization } from "../shared/context-utilization.ts";
12
12
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
13
13
  import type { WorktreeCleanup } from "../shared/worktree.ts";
14
14
  import type { AcceptanceLedger } from "./acceptance.ts";
15
+ import {
16
+ projectWorkflowGraph,
17
+ type WorkflowGraphProjection,
18
+ type WorkflowGraphRecord,
19
+ } from "./graph-projection.ts";
20
+ import type { InvocationRecord } from "./invocation-ledger.ts";
15
21
  import { safeStringify } from "./serialization.ts";
16
22
 
17
23
  export type Theme = ExtensionContext["ui"]["theme"];
@@ -65,6 +71,15 @@ export interface TranscriptEntry {
65
71
 
66
72
  export interface AgentRecord {
67
73
  index: number;
74
+ /** Stable identity and independent intent/admission/execution status planes. */
75
+ callId?: string;
76
+ invocation?: InvocationRecord;
77
+ /** Ephemeral same-run child Session reused for this logical operator. */
78
+ operatorKey?: string;
79
+ /** Explicit result-reference dependencies used by the derived graph. */
80
+ inputCallIds?: string[];
81
+ /** Opaque same-run reference returned to the workflow script. */
82
+ resultRef?: string;
68
83
  label: string;
69
84
  phase?: string;
70
85
  state: AgentState;
@@ -122,9 +137,46 @@ export interface WorkflowDetails {
122
137
  resumedFrom?: string;
123
138
  /** Why a requested resume produced no cache, for an honest result message. */
124
139
  resumeNote?: string;
140
+ /** Read-only lineage projection; never execution or admission authority. */
141
+ graph?: WorkflowGraphProjection;
125
142
  error?: string;
126
143
  }
127
144
 
145
+ export function workflowGraphRecords(
146
+ agents: readonly AgentRecord[],
147
+ ): WorkflowGraphRecord[] {
148
+ return agents.flatMap((agent) =>
149
+ agent.callId
150
+ ? [
151
+ {
152
+ callId: agent.callId,
153
+ index: agent.index,
154
+ label: agent.label,
155
+ state: agent.state,
156
+ ...(agent.invocation
157
+ ? {
158
+ admissionState: agent.invocation.admissionState,
159
+ executionState: agent.invocation.executionState,
160
+ }
161
+ : {}),
162
+ ...(agent.operatorKey ? { operatorKey: agent.operatorKey } : {}),
163
+ ...(agent.inputCallIds
164
+ ? { inputCallIds: [...agent.inputCallIds] }
165
+ : {}),
166
+ ...(agent.resultRef ? { resultRef: agent.resultRef } : {}),
167
+ },
168
+ ]
169
+ : [],
170
+ );
171
+ }
172
+
173
+ /** Refresh the descriptive graph without granting it scheduling authority. */
174
+ export function refreshWorkflowGraph(details: WorkflowDetails) {
175
+ const records = workflowGraphRecords(details.agents);
176
+ details.graph = projectWorkflowGraph(records);
177
+ return details.graph;
178
+ }
179
+
128
180
  export const MAX_LOG_ENTRIES = 100;
129
181
  export const MAX_LOG_TEXT = 300;
130
182
 
@@ -0,0 +1,131 @@
1
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface WorkflowOperatorActivation {
4
+ key: string;
5
+ fingerprint: string;
6
+ cwd: string;
7
+ signal?: AbortSignal;
8
+ }
9
+
10
+ type OperatorEntry = {
11
+ fingerprint: string;
12
+ cwd: string;
13
+ sessionManager: SessionManager;
14
+ tail: Promise<void>;
15
+ };
16
+
17
+ export function normalizeWorkflowOperatorKey(key: string) {
18
+ if (typeof key !== "string" || !/^[A-Za-z0-9._:-]{1,80}$/.test(key)) {
19
+ throw new Error(
20
+ "Workflow operator key must be 1-80 ASCII letters, digits, or ._:- characters",
21
+ );
22
+ }
23
+ return key;
24
+ }
25
+
26
+ function abortError(signal: AbortSignal) {
27
+ if (signal.reason instanceof Error) return signal.reason;
28
+ const error = new Error("Workflow operator activation was aborted");
29
+ error.name = "AbortError";
30
+ return error;
31
+ }
32
+
33
+ function invokeAfter<T>(
34
+ turn: Promise<void>,
35
+ signal: AbortSignal | undefined,
36
+ invoke: () => T | PromiseLike<T>,
37
+ ) {
38
+ if (!signal) return turn.then(invoke);
39
+ if (signal.aborted) return Promise.reject(abortError(signal));
40
+
41
+ return new Promise<T>((resolve, reject) => {
42
+ let waiting = true;
43
+ const onAbort = () => {
44
+ if (!waiting) return;
45
+ waiting = false;
46
+ signal.removeEventListener("abort", onAbort);
47
+ reject(abortError(signal));
48
+ };
49
+ signal.addEventListener("abort", onAbort, { once: true });
50
+ void turn.then(
51
+ () => {
52
+ if (!waiting) return;
53
+ if (signal.aborted) {
54
+ onAbort();
55
+ return;
56
+ }
57
+ waiting = false;
58
+ signal.removeEventListener("abort", onAbort);
59
+ try {
60
+ Promise.resolve(invoke()).then(resolve, reject);
61
+ } catch (error) {
62
+ reject(error);
63
+ }
64
+ },
65
+ (error) => {
66
+ if (!waiting) return;
67
+ waiting = false;
68
+ signal.removeEventListener("abort", onAbort);
69
+ reject(error);
70
+ },
71
+ );
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Per-run, in-process operator sessions. Nothing in this registry is persisted
77
+ * or shared with another process.
78
+ */
79
+ export class WorkflowOperatorRegistry {
80
+ private readonly operators = new Map<string, OperatorEntry>();
81
+ private closed = false;
82
+ private closePromise?: Promise<void>;
83
+
84
+ activate<T>(
85
+ activation: WorkflowOperatorActivation,
86
+ invoke: (sessionManager: SessionManager) => T | PromiseLike<T>,
87
+ ) {
88
+ if (this.closed) {
89
+ return Promise.reject(new Error("Workflow operator registry is closed"));
90
+ }
91
+
92
+ const key = normalizeWorkflowOperatorKey(activation.key);
93
+ let operator = this.operators.get(key);
94
+ if (!operator) {
95
+ operator = {
96
+ fingerprint: activation.fingerprint,
97
+ cwd: activation.cwd,
98
+ sessionManager: SessionManager.inMemory(activation.cwd),
99
+ tail: Promise.resolve(),
100
+ };
101
+ this.operators.set(key, operator);
102
+ } else if (
103
+ operator.fingerprint !== activation.fingerprint ||
104
+ operator.cwd !== activation.cwd
105
+ ) {
106
+ return Promise.reject(
107
+ new Error(`Workflow operator identity mismatch for "${key}"`),
108
+ );
109
+ }
110
+ const previous = operator.tail;
111
+ const result = invokeAfter(previous, activation.signal, () =>
112
+ invoke(operator.sessionManager),
113
+ );
114
+ operator.tail = Promise.allSettled([previous, result]).then(
115
+ () => undefined,
116
+ );
117
+ return result;
118
+ }
119
+
120
+ close() {
121
+ if (this.closePromise) return this.closePromise;
122
+ this.closed = true;
123
+ const queues = [...this.operators.values()].map(
124
+ (operator) => operator.tail,
125
+ );
126
+ this.closePromise = Promise.allSettled(queues).then(() => {
127
+ this.operators.clear();
128
+ });
129
+ return this.closePromise;
130
+ }
131
+ }
@@ -50,27 +50,29 @@ export const WORKFLOW_TOOL_DESCRIPTION = [
50
50
  "• phase(title) — mark the current phase at runtime (use titles from meta.phases).",
51
51
  "• log(message) — emit one progress line to the user and to your own final report. This is the run's narrator: use it for anything the reader needs while the run is still going, or that the return value would not capture — round counts, how many agents were dropped, why a branch was skipped. Unlike phase(), it does not touch the phase list. Lines are one row each (newlines are flattened); the most recent 100 are kept and any earlier ones are reported as dropped.",
52
52
  "• usage() — read this run's cumulative token spend so far: { input, output, cacheRead, cacheWrite, total, cost, agents }. The reading refreshes as each agent settles, so evaluating it right after an `await` reflects that agent. `total` never decreases, but it is a LOWER BOUND rather than an exact figure: a child session that compacts drops the tokens of the messages it discarded. Use it to report or adapt cost — e.g. log a running total, or stop a discovery loop once the spend stops paying for itself — and expect a long run to have spent somewhat more than it says. It is a reading, not a limit: nothing is enforced for you.",
53
- "• await agent(prompt, { agent_type?, label?, phase?, schema?, acceptance?, model?, provider?, effort?, isolation? }) — run ONE subagent in an isolated context and wait for it. `agent_type` applies the same named preset and enforced capabilities as subagent_spawn: specialized system prompt, tool allowlist, model assignment, and default effort. Prefer a matching type when one exists. Model precedence is explicit model/provider > type-file model > configured built-in role model > parent model; effort precedence is explicit effort > type default > parent effort. Omit `agent_type` for a general-purpose child. Always resolves to { ok, output, structured?, acceptance?, error? }. Check `ok` before using the result. When you pass a JSON `schema`, `structured` holds the validated object on success. Optional `acceptance: { criteria: [{ id, description, requiredEvidence? }] }` is explicit and adds no extra agent: the same structured result must include an evidence ledger; missing, malformed, or rejected criteria make `ok:false` while preserving output and ledger. Children receive normal built-ins and trust-appropriate extensions, settings, skills, and AGENTS.md context, but cannot recursively orchestrate or ask the user.",
53
+ "• await agent(prompt, { agent_type?, label?, phase?, schema?, acceptance?, model?, provider?, effort?, isolation?, operator?, inputs? }) — run ONE subagent and wait for it. `agent_type` applies the same named preset and enforced capabilities as subagent_spawn: specialized system prompt, tool allowlist, model assignment, and default effort. Prefer a matching type when one exists. Model precedence is explicit model/provider > type-file model > configured built-in role model > parent model; effort precedence is explicit effort > type default > parent effort. Omit `agent_type` for a general-purpose child. Always resolves to { ok, output, structured?, ref?, acceptance?, error? }. Check `ok` before using the result. A successful call's opaque result ref can be passed through `inputs: [previous.ref]` to hydrate bounded same-run handoffs as untrusted data and record explicit lineage. When you pass a JSON `schema`, `structured` holds the validated object on success. Optional `acceptance: { criteria: [{ id, description, requiredEvidence? }] }` is explicit and adds no extra agent: the same structured result must include an evidence ledger; missing, malformed, or rejected criteria make `ok:false` while preserving output and ledger. Children receive normal built-ins and trust-appropriate extensions, settings, skills, and AGENTS.md context, but cannot recursively orchestrate or ask the user.",
54
+ "• operator: 'name' reuses one in-memory child Session for serialized follow-up activations inside the same workflow run. Its model, role/tool surface, effort, structured mode, and cwd are frozen by the first activation. Operator calls cannot use per-call worktrees or result replay, and operator continuity is not a cross-restart guarantee.",
55
+ "• inputs: [resultRef, ...] accepts only opaque refs issued by successful calls in this same workflow run. The host injects at most 16 KiB per conclusion and 48 KiB total, marks it as data rather than instructions, and derives a read-only graph from those explicit refs. The graph is observability, never scheduling authority.",
54
56
  "• isolation: 'worktree' runs that one agent in its own git worktree on its own branch, instead of the shared working directory. Use it for any fan-out where agents WRITE — without it, concurrent agents share one checkout and one git index, so their edits and `git add`s silently overwrite each other. Tell such an agent to COMMIT its work: on completion the worktree directory is reclaimed and its branch is kept for you to merge (an empty branch is deleted; uncommitted changes keep the directory instead). The branch name comes back in the run artifacts. Costs a fresh checkout, needs a git repo, and starts without gitignored files, so leave it off for read-only agents.",
55
- "• await parallel([() => agent(...), () => agent(...)], { concurrency? }) — run zero-argument agent thunks concurrently and return results in order. This is a BARRIER: nothing after it starts until every thunk settles. A thunk that throws settles to null (filter it out) rather than failing the whole batch, so one bad item never discards the others' results. The package default is 8 concurrent agents per workflow and can be changed with /my-pi-setup (hard maximum 64).",
57
+ "• await parallel([() => agent(...), () => agent(...)], { concurrency? }) — run zero-argument agent thunks concurrently and return results in order. This is a BARRIER: nothing after it starts until every thunk settles. A thunk that throws settles to null (filter it out) rather than failing the whole batch, so one bad item never discards the others' results. The package default is 8 concurrent agents per workflow and can be changed with /openpi-setup (hard maximum 64).",
56
58
  "• await pipeline(items, stage1, stage2, ...) — run each item through every stage independently, with NO barrier between stages: item A can be in stage 3 while item B is still in stage 1. Results come back in input order. Each stage receives (previousResult, originalItem, index), so a later stage can label its work without threading context through the earlier stage's return value. A stage that throws drops that item to null and skips its remaining stages, leaving siblings untouched.",
57
59
  "PREFER pipeline() for multi-stage work. parallel() forces every item to wait for the slowest one in each stage, so wall-clock becomes the sum of per-stage worst cases (max stage1 + max stage2) instead of the slowest single chain. The gap is widest when different items are slow in different stages; when one item is slowest everywhere it is the critical path either way. Reach for a barrier only when a stage genuinely needs cross-item context from ALL of the previous one: deduping or merging the full result set, exiting early when the total count is zero, or a prompt that compares one finding against the others. Needing to flatten/map/filter in between is NOT a reason — do that inside a pipeline stage.",
58
60
 
59
61
  "• args — the parsed value of the `args` tool parameter (or undefined).",
60
- "Workflow JavaScript runs in a restricted, killable child with no imports, eval, timers, filesystem, network, or process APIs. The package default permits 128 agent calls per run and can be changed with /my-pi-setup (hard maximum 1024); there is no overall deadline. Each agent must receive its first assistant response event within 45 seconds so silent provider requests fail clearly; after that, agent() has no wall-clock deadline. Each individual child tool call times out independently after 3 minutes, becomes an error tool result, and leaves the agent loop free to recover. Use map/filter/if/await/template strings to orchestrate, and `return` a JSON-serializable aggregate.",
61
- "Pass a `schema` to agent() whenever a later step branches on the result, so you get typed fields instead of prose. Artifacts are saved under ~/.pi/agent/workflows/<runId>/ for inspection. To re-run an edited workflow cheaply, pass `resume_from_run_id` with the previous run id: only provably read-only calls whose content and project/resource context are unchanged can replay; writable, unrestricted, unknown-tool, and worktree calls always run for real.",
62
+ "Workflow JavaScript runs in a restricted, killable child with no imports, eval, timers, filesystem, network, or process APIs. The package default permits 128 agent calls per run and can be changed with /openpi-setup (hard maximum 1024); there is no overall deadline. Each agent must receive its first assistant response event within 45 seconds so silent provider requests fail clearly; after that, agent() has no wall-clock deadline. Each individual child tool call times out independently after 3 minutes, becomes an error tool result, and leaves the agent loop free to recover. Use map/filter/if/await/template strings to orchestrate, and `return` a JSON-serializable aggregate.",
63
+ "Pass a `schema` to agent() whenever a later step branches on the result, so you get typed fields instead of prose. Each call persists independent intent, admission, and execution state; interrupted nonterminal calls are reported as uncertain rather than guessed failed. Artifacts include a bounded graph projection for explicit result-ref dependencies. To re-run an edited workflow cheaply, pass `resume_from_run_id` with the previous run id: only provably read-only non-operator calls whose content and project/resource context are unchanged can replay; writable, unrestricted, unknown-tool, operator, and worktree calls always run for real.",
62
64
  "Example — each file is verified as soon as ITS OWN scan lands, instead of waiting for every scan:",
63
65
  "export const meta = { name: 'reliability-review', description: 'Review modules for reliability risks, then report', phases: [{ title: 'Scan' }, { title: 'Verify' }, { title: 'Report' }] }",
64
66
  "const FINDINGS = { type: 'object', properties: { issues: { type: 'array', items: { type: 'string' } }, ok: { type: 'boolean' } }, required: ['issues', 'ok'] }",
65
67
  "phase('Scan')",
66
68
  "const checked = await pipeline(args.files,",
67
69
  " (f) => agent(`Trace ${f} for candidate reliability risks with file:line evidence.`, { agent_type: 'explorer', label: `scan:${f}`, phase: 'Scan', schema: FINDINGS }),",
68
- " (scan, f) => scan.ok ? agent(`Review whether these candidate issues in ${f} are real: ${JSON.stringify(scan.structured.issues)}`, { agent_type: 'reviewer', label: `verify:${f}`, phase: 'Verify' }) : null)",
70
+ " (scan, f) => scan.ok ? agent(`Review the candidate issues in ${f}.`, { agent_type: 'reviewer', label: `verify:${f}`, phase: 'Verify', inputs: [scan.ref] }) : null)",
69
71
  "const verified = checked.filter((r) => r && r.ok)",
70
72
  "const dropped = checked.length - verified.length // agents that failed/dropped: surface, never silently swallow",
71
73
  "if (dropped) log(`${dropped}/${checked.length} file(s) dropped before verification`)",
72
74
  "phase('Report')",
73
- "const report = await agent(`Synthesize tradeoffs and recommendations from these verified findings: ${JSON.stringify(verified.map((r) => r.output))}`, { agent_type: 'advisor', label: 'report', phase: 'Report' })",
75
+ "const report = await agent('Synthesize tradeoffs and recommendations from the verified findings.', { agent_type: 'advisor', label: 'report', phase: 'Report', inputs: verified.map((r) => r.ref) })",
74
76
  "log(`done — ${verified.length} verified, ${usage().total} tokens`)",
75
77
  "return { verified: verified.length, dropped, report: report.ok ? report.output : report.error }",
76
78
  ].join("\n");
@@ -97,6 +97,8 @@ export interface RunAgentOptions {
97
97
  cwd: string;
98
98
  loader: DefaultResourceLoader;
99
99
  settingsManager: SettingsManager;
100
+ /** Optional per-run manager reused by one logical workflow operator. */
101
+ sessionManager?: SessionManager;
100
102
  modelRegistry: ExtensionContext["modelRegistry"];
101
103
  /** Agent Type allowlist; childToolPolicy can only narrow capabilities. */
102
104
  tools?: readonly string[];
@@ -465,6 +467,8 @@ export function createFirstResponseWatchdog(
465
467
  const timeoutMs = options.timeoutMs ?? FIRST_RESPONSE_TIMEOUT_MS;
466
468
  let timer: ReturnType<typeof setTimeout> | undefined;
467
469
  const timeout = new Promise<never>((_resolve, reject) => {
470
+ // This timer owns the awaited watchdog outcome. Keep it referenced so a
471
+ // short-lived Node 22 process cannot exit with the promise still pending.
468
472
  timer = setTimeout(() => {
469
473
  timer = undefined;
470
474
  const model = options.model ? ` for ${options.model}` : "";
@@ -475,7 +479,6 @@ export function createFirstResponseWatchdog(
475
479
  );
476
480
  void onTimeout().catch(() => {});
477
481
  }, timeoutMs);
478
- timer.unref?.();
479
482
  });
480
483
 
481
484
  const cancel = () => {
@@ -485,6 +488,7 @@ export function createFirstResponseWatchdog(
485
488
 
486
489
  return {
487
490
  markResponse: cancel,
491
+ cancel,
488
492
  async waitFor<T>(operation: Promise<T>) {
489
493
  try {
490
494
  return await Promise.race([operation, timeout]);
@@ -533,7 +537,8 @@ export async function runAgent(
533
537
  : {}),
534
538
  resourceLoader: options.loader,
535
539
  settingsManager: options.settingsManager,
536
- sessionManager: SessionManager.inMemory(options.cwd),
540
+ sessionManager:
541
+ options.sessionManager ?? SessionManager.inMemory(options.cwd),
537
542
  ...(customTools ? { customTools } : {}),
538
543
  ...childToolPolicy(childTools),
539
544
  }));
@@ -619,6 +624,7 @@ export async function runAgent(
619
624
  };
620
625
 
621
626
  let markFirstResponse = () => {};
627
+ let cancelFirstResponseWatchdog = () => {};
622
628
  const unsubscribe = childSession.subscribe((event) => {
623
629
  if (settled) return;
624
630
  if (isAssistantResponseEvent(event)) markFirstResponse();
@@ -693,6 +699,7 @@ export async function runAgent(
693
699
  },
694
700
  );
695
701
  markFirstResponse = watchdog.markResponse;
702
+ cancelFirstResponseWatchdog = watchdog.cancel;
696
703
  await Promise.race([
697
704
  watchdog.waitFor(
698
705
  childSession.prompt(buildWorkflowAgentPrompt(options.prompt)),
@@ -703,6 +710,7 @@ export async function runAgent(
703
710
  } catch (error) {
704
711
  promptErrorMessage = errorText(error);
705
712
  } finally {
713
+ cancelFirstResponseWatchdog();
706
714
  options.signal?.removeEventListener("abort", onAbort);
707
715
  settled = true;
708
716
  unsubscribe();