@osolmaz/pi-workflows 0.1.0 → 0.3.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.
Files changed (171) hide show
  1. package/README.md +107 -26
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
@@ -1,27 +1,55 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "typebox";
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ projectControllerStorePath,
5
+ type RunEventRecord,
6
+ SqliteControllerStore,
7
+ } from "../controllers/index.js";
8
+ import type { JsonObject } from "../controllers/types.js";
9
+ import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
7
10
  import { WorkflowEngine } from "../workflows/engine.js";
8
- import { errorMessage } from "../workflows/errors.js";
9
- import { discoverWorkflows, loadWorkflowFile, resolveWorkflowRef } from "../workflows/loader.js";
10
- import { createDefinitionSnapshot } from "../workflows/store.js";
11
+ import { ClaimLostError, errorMessage, isClaimLostError } from "../workflows/errors.js";
12
+ import {
13
+ discoverWorkflows,
14
+ hashWorkflowSource,
15
+ loadWorkflowFile,
16
+ resolveWorkflowRef,
17
+ } from "../workflows/loader.js";
18
+ import {
19
+ createRunId,
20
+ listRunBundles,
21
+ readLastTraceEvent,
22
+ readRunBundle,
23
+ WorkflowRunStore,
24
+ createDefinitionSnapshot,
25
+ } from "../workflows/store.js";
11
26
  import type {
12
27
  WorkflowDefinition,
13
28
  WorkflowDefinitionSnapshot,
14
29
  WorkflowRunResult,
15
30
  WorkflowRunState,
16
31
  } from "../workflows/types.js";
32
+ import {
33
+ PiControllerHost,
34
+ parseControllerArgs,
35
+ type PiChildWorkflowStarter,
36
+ } from "./controller-host.js";
17
37
  import { ConversationStepExecutor } from "./executor.js";
38
+ import { SessionRecorder } from "./recorder.js";
18
39
  import { buildWidgetView } from "./widget.js";
40
+ import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
19
41
 
42
+ const RUN_CLAIM_LEASE_MS = 30_000;
43
+ const RUN_CLAIM_RENEW_MS = 10_000;
44
+ const RUN_SYNC_POLL_MS = 3_000;
20
45
  const WIDGET_KEY = "pi-workflows";
21
46
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
22
47
  const FINAL_WIDGET_TTL_MS = 60_000;
23
48
  const WIDGET_SCROLL_STEP = 3;
24
49
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
50
+ const MAX_STATUS_ERROR_CHARS = 4_000;
51
+ const MAX_WORKFLOW_LIST_ITEMS = 50;
52
+ const MAX_WORKFLOW_LIST_NAME_CHARS = 3_500;
25
53
  const PRESENTATION_TIMEOUT_MS = 30_000;
26
54
 
27
55
  class PresentationSupersededError extends Error {}
@@ -33,14 +61,40 @@ type PresentationPromptBuilder = Exclude<
33
61
  >;
34
62
 
35
63
  type ActiveRun = {
36
- runId: string | null;
64
+ runId: string;
37
65
  workflowName: string;
38
66
  engine: WorkflowEngine;
39
67
  executor: ConversationStepExecutor;
68
+ recorder: SessionRecorder | null;
40
69
  snapshot: WorkflowDefinitionSnapshot;
41
70
  presentationPrompt: WorkflowDefinition["presentationPrompt"];
42
71
  generation: number;
43
72
  lastState: WorkflowRunState | null;
73
+ childKey?: string;
74
+ onFinish?: (result: WorkflowSchedulerResult) => void;
75
+ completion?: Promise<void>;
76
+ interruptionRequested?: boolean;
77
+ claimToken?: string | undefined;
78
+ renewTimer?: ReturnType<typeof setInterval> | undefined;
79
+ /** True for runs this session resumed from the queue. */
80
+ resume?: boolean | undefined;
81
+ /** Set on continuation runs: the checkpointed parent run id. */
82
+ parentRunId?: string | undefined;
83
+ };
84
+
85
+ type StartRunOptions = {
86
+ runId?: string;
87
+ childKey?: string;
88
+ onFinish?: (result: WorkflowSchedulerResult) => void;
89
+ presentation?: boolean;
90
+ quiet?: boolean;
91
+ signal?: AbortSignal;
92
+ /** Continue a checkpointed run: input becomes the answer payload. */
93
+ parentRunId?: string;
94
+ /** Resume a parked run at its stopped node instead of starting fresh. */
95
+ resume?: boolean;
96
+ /** An existing queue claim token, when the caller already claimed the run. */
97
+ claimToken?: string;
44
98
  };
45
99
 
46
100
  export type ParsedWorkflowArgs =
@@ -48,6 +102,8 @@ export type ParsedWorkflowArgs =
48
102
  | { kind: "cancel" }
49
103
  | { kind: "pause" }
50
104
  | { kind: "resume" }
105
+ | { kind: "status"; runId?: string }
106
+ | { kind: "answer"; input: unknown; runId?: string | undefined }
51
107
  | { kind: "run"; ref: string; input: unknown };
52
108
 
53
109
  /** Parse `/workflow` arguments. Exported for tests. */
@@ -59,6 +115,52 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
59
115
  if (trimmed === "cancel" || trimmed === "pause" || trimmed === "resume") {
60
116
  return { kind: trimmed };
61
117
  }
118
+ if (trimmed === "status") {
119
+ return { kind: "status" };
120
+ }
121
+ if (trimmed.startsWith("status ")) {
122
+ const runId = trimmed.slice("status".length).trim();
123
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
124
+ throw new Error("status requires one valid run id");
125
+ }
126
+ return { kind: "status", runId };
127
+ }
128
+ if (trimmed === "answer" || trimmed.startsWith("answer ")) {
129
+ let rest = trimmed === "answer" ? "" : trimmed.slice("answer".length).trim();
130
+ if (rest.length === 0) {
131
+ throw new Error(
132
+ 'answer requires a JSON value or text, e.g. /workflow answer {"approved":true}',
133
+ );
134
+ }
135
+ // `/workflow answer <run-id> <json>` targets a specific waiting run,
136
+ // for example after a restart or for host-driven runs.
137
+ let runId: string | undefined;
138
+ const firstSpace = rest.search(/\s/);
139
+ if (firstSpace > 0) {
140
+ const candidate = rest.slice(0, firstSpace);
141
+ const remainder = rest.slice(firstSpace).trim();
142
+ if (
143
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(candidate) &&
144
+ remainder.length > 0 &&
145
+ (remainder.startsWith("{") || remainder.startsWith("["))
146
+ ) {
147
+ runId = candidate;
148
+ rest = remainder;
149
+ }
150
+ }
151
+ try {
152
+ return {
153
+ kind: "answer",
154
+ input: JSON.parse(rest) as unknown,
155
+ ...(runId !== undefined ? { runId } : {}),
156
+ };
157
+ } catch {
158
+ if (runId !== undefined) {
159
+ throw new Error("answer with a run id requires a JSON value");
160
+ }
161
+ return { kind: "answer", input: { answer: rest } };
162
+ }
163
+ }
62
164
  const spaceIndex = trimmed.search(/\s/);
63
165
  const ref = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
64
166
  const rest = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex).trim();
@@ -75,13 +177,185 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
75
177
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
76
178
  }
77
179
 
180
+ function workflowStateSummary(state: WorkflowRunState): JsonObject {
181
+ const error =
182
+ state.error === undefined
183
+ ? undefined
184
+ : state.error.length <= MAX_STATUS_ERROR_CHARS
185
+ ? state.error
186
+ : `${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}\n… [error truncated]`;
187
+ return {
188
+ active: state.status === "running",
189
+ runId: state.runId,
190
+ workflowName: state.workflowName,
191
+ status: state.status,
192
+ steps: state.steps.length,
193
+ ...(state.currentNode !== undefined ? { currentNode: state.currentNode } : {}),
194
+ ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
195
+ ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
196
+ ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
197
+ ...(error !== undefined ? { error } : {}),
198
+ };
199
+ }
200
+
78
201
  type WidgetSource = {
79
202
  state: WorkflowRunState;
80
203
  snapshot: WorkflowDefinitionSnapshot;
81
204
  };
82
205
 
83
206
  export default function piWorkflows(pi: ExtensionAPI) {
207
+ // One runner identity per session; it names this session in run claims.
208
+ const runnerId = randomUUID();
209
+ let runQueueStore: SqliteControllerStore | null = null;
210
+ const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
211
+ runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
212
+ return runQueueStore;
213
+ };
214
+
215
+ // Session sync: a per-session watermark over the run event feed keeps this
216
+ // session's context current with runs other runners drove.
217
+ // The sync watermark is project-scoped: a reopened session catches up
218
+ // where the last one stopped, and two open sessions share one pointer.
219
+ const SYNC_WATERMARK_KEY = "project";
220
+ let syncArmed = false;
221
+ let runSyncTimer: ReturnType<typeof setInterval> | null = null;
222
+
223
+ const recordRunEvent = (event: {
224
+ runId: string;
225
+ workflowRef: string;
226
+ type: string;
227
+ payload?: JsonObject;
228
+ }) => {
229
+ try {
230
+ runQueueStore?.recordRunEvent({ ...event, runnerId });
231
+ } catch {
232
+ // The event feed is best-effort; never fail a run for it.
233
+ }
234
+ };
235
+
236
+ const describeRunEvent = (event: RunEventRecord): string => {
237
+ const label = `${event.workflowRef} run ${event.runId}`;
238
+ switch (event.type) {
239
+ case "waiting": {
240
+ const waitingOn =
241
+ typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
242
+ return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
243
+ }
244
+ case "parked":
245
+ return `${label} was parked and will resume when a runner is available`;
246
+ case "failed": {
247
+ const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
248
+ return `${label} failed${detail}`;
249
+ }
250
+ default:
251
+ return `${label} ${event.type}`;
252
+ }
253
+ };
254
+
255
+ const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
256
+ if (runQueueStore === null || !syncArmed) {
257
+ return;
258
+ }
259
+ try {
260
+ const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
261
+ if (watermark === 0) {
262
+ // First sync ever for this project: never replay the feed (stale
263
+ // "waits at checkpoint" lines included). Fast-forward, then catch
264
+ // up from current state instead — what is parked, resuming, or
265
+ // waiting for an answer right now.
266
+ const latest = runQueueStore.latestRunEventSeq();
267
+ if (latest > 0) {
268
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
269
+ }
270
+ await sendStateSnapshot(ctx);
271
+ return;
272
+ }
273
+ const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
274
+ if (events.length === 0) {
275
+ return;
276
+ }
277
+ // Persist the watermark first. A crash after this point skips the
278
+ // message, but snapshots recompute from the store, so no information
279
+ // stays lost; a duplicated state line is the worst outcome.
280
+ runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
281
+ const noteworthy = events.filter(
282
+ (event) =>
283
+ event.runnerId !== runnerId &&
284
+ ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
285
+ event.type,
286
+ ),
287
+ );
288
+ if (noteworthy.length === 0) {
289
+ return;
290
+ }
291
+ const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
292
+ pi.sendMessage(
293
+ { customType: "pi-workflows-run-sync", content, display: false },
294
+ { deliverAs: "steer", triggerTurn: false },
295
+ );
296
+ notify(ctx, noteworthy.map(describeRunEvent).join("; "));
297
+ } catch {
298
+ // Sync is observational.
299
+ }
300
+ };
301
+
302
+ // The first-use catch-up: a snapshot of runs that need attention now.
303
+ const sendStateSnapshot = async (ctx: ExtensionContext) => {
304
+ if (runQueueStore === null) {
305
+ return;
306
+ }
307
+ const lines: string[] = [];
308
+ const rows = runQueueStore.listWorkflowRuns();
309
+ for (const row of rows) {
310
+ if (row.status === "parked") {
311
+ lines.push(`${row.workflowRef} run ${row.runId} is parked and will resume`);
312
+ }
313
+ }
314
+ const known = new Set(rows.map((row) => row.runId));
315
+ const continued = new Set(
316
+ rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
317
+ );
318
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
319
+ for (const bundle of bundles) {
320
+ if (
321
+ bundle.state.status === "waiting" &&
322
+ known.has(bundle.state.runId) &&
323
+ !continued.has(bundle.state.runId)
324
+ ) {
325
+ lines.push(
326
+ `${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
327
+ );
328
+ }
329
+ }
330
+ if (lines.length === 0) {
331
+ return;
332
+ }
333
+ const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
334
+ pi.sendMessage(
335
+ { customType: "pi-workflows-run-sync", content, display: false },
336
+ { deliverAs: "steer", triggerTurn: false },
337
+ );
338
+ notify(ctx, lines.join("; "));
339
+ };
340
+
341
+ const startRunSync = (ctx: ExtensionContext) => {
342
+ if (runSyncTimer !== null) {
343
+ return;
344
+ }
345
+ ensureRunQueueStore(ctx.cwd);
346
+ void runSyncPass(ctx);
347
+ runSyncTimer = setInterval(() => void runSyncPass(ctx), RUN_SYNC_POLL_MS);
348
+ runSyncTimer.unref?.();
349
+ };
84
350
  let activeRun: ActiveRun | null = null;
351
+ let pendingToolLaunch: {
352
+ ctx: ExtensionContext;
353
+ ref: string;
354
+ input: unknown;
355
+ options?: StartRunOptions;
356
+ } | null = null;
357
+ // The interactive run currently parked at a checkpoint, if any.
358
+ let lastWaitingRunId: string | null = null;
85
359
  let widgetTimer: NodeJS.Timeout | null = null;
86
360
  let widgetTicker: NodeJS.Timeout | null = null;
87
361
  // Manual widget scroll: null follows the active node; a number is the
@@ -95,6 +369,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
95
369
  let runGeneration = 0;
96
370
  let presentationAbort: AbortController | null = null;
97
371
  let presentationPending: number | null = null;
372
+ let controllerHost: PiControllerHost | undefined;
373
+ let controllerContext: ExtensionContext | null = null;
98
374
 
99
375
  // UI updates are best-effort: a captured ctx becomes stale after session
100
376
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
@@ -290,10 +566,63 @@ export default function piWorkflows(pi: ExtensionAPI) {
290
566
  }
291
567
  };
292
568
 
293
- const finishRun = (ctx: ExtensionContext, run: ActiveRun, result: WorkflowRunResult) => {
569
+ // Release the queue claim exactly once. "done" marks the queue row
570
+ // terminal, "park" leaves it claimable for another runner, and "lost"
571
+ // leaves it for the new claim holder.
572
+ const releaseClaim = (run: ActiveRun, outcome: "done" | "lost" | "park") => {
573
+ if (run.renewTimer !== undefined) {
574
+ clearInterval(run.renewTimer);
575
+ run.renewTimer = undefined;
576
+ }
577
+ if (run.claimToken === undefined || runQueueStore === null || outcome === "lost") {
578
+ return;
579
+ }
580
+ try {
581
+ if (outcome === "park") {
582
+ runQueueStore.parkWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
583
+ } else {
584
+ runQueueStore.completeWorkflowRun({ runId: run.runId, claimToken: run.claimToken });
585
+ }
586
+ } catch {
587
+ // Queue bookkeeping is best-effort next to the durable bundle.
588
+ } finally {
589
+ run.claimToken = undefined;
590
+ }
591
+ };
592
+
593
+ const finishRun = async (
594
+ ctx: ExtensionContext,
595
+ run: ActiveRun,
596
+ result: WorkflowRunResult,
597
+ ): Promise<void> => {
294
598
  if (activeRun === run) {
295
599
  activeRun = null;
296
600
  }
601
+ if (result.state.status === "running") {
602
+ // The run was parked for another runner; its bundle stays resumable
603
+ // and its queue row becomes claimable. The recorder drains first: its
604
+ // finalization writes share the fenced store, so the claim must stay
605
+ // valid until capture is complete. Nothing else to present.
606
+ await run.recorder?.stop().catch(() => undefined);
607
+ releaseClaim(run, "park");
608
+ recordRunEvent({ runId: run.runId, workflowRef: run.workflowName, type: "parked" });
609
+ stopWidgetTicker();
610
+ clearWidget(ctx);
611
+ return;
612
+ }
613
+ releaseClaim(run, "done");
614
+ recordRunEvent({
615
+ runId: run.runId,
616
+ workflowRef: run.workflowName,
617
+ type: result.state.status,
618
+ payload: {
619
+ ...(result.state.error !== undefined ? { error: result.state.error } : {}),
620
+ ...(result.state.waitingOn !== undefined ? { waitingOn: result.state.waitingOn } : {}),
621
+ },
622
+ });
623
+ // Normally already stopped via onRunFinishing; this covers observers of
624
+ // runs that ended without reaching that hook.
625
+ void run.recorder?.stop();
297
626
  stopWidgetTicker();
298
627
  const { state } = result;
299
628
  updateWidget(ctx, state, run.snapshot);
@@ -304,100 +633,763 @@ export default function piWorkflows(pi: ExtensionAPI) {
304
633
  widgetTimer = setTimeout(() => clearWidget(ctx), FINAL_WIDGET_TTL_MS);
305
634
  widgetTimer.unref?.();
306
635
  }
636
+ if (state.status === "waiting" && run.childKey === undefined) {
637
+ lastWaitingRunId = run.runId;
638
+ }
307
639
  const summary =
308
640
  state.status === "waiting" && state.waitingOn
309
- ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — run ended, awaiting your decision (run ${state.runId})`
641
+ ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
310
642
  : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})`;
311
643
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
644
+ try {
645
+ const childResult =
646
+ run.childKey !== undefined &&
647
+ (sessionClosed || run.interruptionRequested === true) &&
648
+ state.status === "cancelled"
649
+ ? {
650
+ state: "interrupted" as const,
651
+ runId: state.runId,
652
+ ...(state.error !== undefined ? { error: state.error } : {}),
653
+ }
654
+ : workflowSchedulerResult(state);
655
+ run.onFinish?.(childResult);
656
+ } catch (error) {
657
+ notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
658
+ }
312
659
  void presentRun(ctx, run, state);
313
660
  };
314
661
 
315
- const startRun = async (ctx: ExtensionCommandContext, ref: string, input: unknown) => {
662
+ const startRun = async (
663
+ ctx: ExtensionContext,
664
+ ref: string,
665
+ input: unknown,
666
+ options: StartRunOptions = {},
667
+ ): Promise<string | undefined> => {
668
+ if (options.signal?.aborted) {
669
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
670
+ }
316
671
  if (activeRun) {
317
- notify(
318
- ctx,
319
- `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`,
320
- "error",
321
- );
322
- return;
672
+ if (!options.quiet) {
673
+ notify(
674
+ ctx,
675
+ `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`,
676
+ "error",
677
+ );
678
+ }
679
+ return undefined;
323
680
  }
324
681
  if (presentationPending !== null) {
325
- notify(ctx, "The previous workflow result is still being presented. Wait for it to finish.");
326
- return;
682
+ if (!options.quiet) {
683
+ notify(
684
+ ctx,
685
+ "The previous workflow result is still being presented. Wait for it to finish.",
686
+ );
687
+ }
688
+ return undefined;
327
689
  }
328
690
  supersedePresentation();
329
691
  const generation = runGeneration;
330
692
  const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
331
693
  const workflow = await loadWorkflowFile(resolved.path);
694
+ if (options.signal?.aborted) {
695
+ throw options.signal.reason ?? new Error("Workflow startup aborted");
696
+ }
332
697
  const snapshot = createDefinitionSnapshot(workflow);
698
+ const workflowHash = await hashWorkflowSource(resolved.path);
699
+ const runId = options.runId ?? createRunId(workflow.name);
700
+
701
+ // Continuations validate the parent before touching the queue: a
702
+ // refused continuation (edited source, missing parent) must not consume
703
+ // the parent's one-continuation slot.
704
+ if (options.parentRunId !== undefined) {
705
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(options.parentRunId));
706
+ if (parent === null || parent.state.status !== "waiting") {
707
+ throw new Error(`Workflow run ${options.parentRunId} is not waiting at a checkpoint`);
708
+ }
709
+ if (parent.state.workflowHash !== undefined && parent.state.workflowHash !== workflowHash) {
710
+ throw new Error(
711
+ `Workflow source changed since run ${options.parentRunId} started; revert the edit to answer its checkpoint`,
712
+ );
713
+ }
714
+ }
715
+
716
+ // Interactive runs are queued and claimed atomically, so this session
717
+ // owns the run from birth (origin affinity). Controller child runs keep
718
+ // their own scheduling and stay out of the run queue. A resume caller
719
+ // arrives with its claim already taken.
720
+ let claimToken = options.claimToken;
721
+ let queueStore: SqliteControllerStore | null = null;
722
+ if (options.childKey === undefined) {
723
+ queueStore = ensureRunQueueStore(ctx.cwd);
724
+ if (claimToken === undefined) {
725
+ const token = randomUUID();
726
+ queueStore.enqueueWorkflowRun({
727
+ runId,
728
+ workflowRef: ref,
729
+ workflowPath: resolved.path,
730
+ input,
731
+ runnerId,
732
+ claimToken: token,
733
+ leaseMs: RUN_CLAIM_LEASE_MS,
734
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
735
+ });
736
+ claimToken = token;
737
+ recordRunEvent({
738
+ runId,
739
+ workflowRef: ref,
740
+ type: "queued",
741
+ payload: options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {},
742
+ });
743
+ }
744
+ }
745
+ let fence: (() => void) | undefined;
746
+ if (queueStore !== null && claimToken !== undefined) {
747
+ const claimedStore = queueStore;
748
+ const token = claimToken;
749
+ fence = () => {
750
+ if (!claimedStore.verifyWorkflowRunClaim({ runId, claimToken: token })) {
751
+ throw new ClaimLostError(runId);
752
+ }
753
+ };
754
+ }
333
755
 
334
756
  const executor = new ConversationStepExecutor({
335
757
  sendPrompt: ({ prompt, streaming }) => {
336
758
  pi.sendUserMessage(prompt, streaming ? { deliverAs: "steer" } : undefined);
337
759
  },
760
+ conversation: {
761
+ beginAttempt: (contract) => run.recorder?.beginAttempt(contract),
762
+ mark: () => run.recorder?.mark() ?? 0,
763
+ rangeSince: (mark) => run.recorder?.rangeSince(mark),
764
+ },
338
765
  });
766
+ // The store is shared between the engine and the session recorder so the
767
+ // trace sequence stays single-writer (see docs/run-bundles.md). Queued
768
+ // runs are fenced: every write proves the claim is still ours first.
769
+ const store = new WorkflowRunStore(
770
+ undefined,
771
+ fence === undefined ? {} : { fenceProvider: () => fence },
772
+ );
339
773
  const engine = new WorkflowEngine({
340
774
  executor,
341
- onEvent: (_event, state: WorkflowRunState) => {
342
- if (run.runId === null) {
343
- run.runId = state.runId;
775
+ store,
776
+ // Awaited by the engine after run_started is persisted, so the session
777
+ // binding and its trace event always precede node and terminal events.
778
+ onRunStarted: async (runDir, state) => {
779
+ const recorder = new SessionRecorder(store, runDir, state.runId);
780
+ try {
781
+ await recorder.bind(ctx);
782
+ run.recorder = recorder;
783
+ } catch {
784
+ // Binding is best-effort: a session without UI access or an
785
+ // ephemeral context must not fail the run.
344
786
  }
787
+ },
788
+ // Awaited by the engine before the terminal snapshot. If completion was
789
+ // submitted from the workflow tool, capture stays open through Pi's
790
+ // final tool, message, and turn hooks before it drains.
791
+ onRunFinishing: async () => {
792
+ await run.recorder?.finish();
793
+ },
794
+ onEvent: (_event, state: WorkflowRunState) => {
345
795
  run.lastState = state;
346
796
  updateWidget(ctx, state, snapshot);
347
797
  },
348
798
  });
349
799
  const run: ActiveRun = {
350
- runId: null,
800
+ runId,
351
801
  workflowName: workflow.name,
352
802
  engine,
353
803
  executor,
804
+ recorder: null,
354
805
  snapshot,
355
- presentationPrompt: workflow.presentationPrompt,
806
+ presentationPrompt: options.presentation === false ? undefined : workflow.presentationPrompt,
356
807
  generation,
357
808
  lastState: null,
809
+ ...(options.childKey !== undefined ? { childKey: options.childKey } : {}),
810
+ ...(options.onFinish !== undefined ? { onFinish: options.onFinish } : {}),
811
+ ...(claimToken !== undefined ? { claimToken } : {}),
812
+ ...(options.resume === true ? { resume: true } : {}),
813
+ ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
358
814
  };
359
815
  activeRun = run;
816
+ if (queueStore !== null && claimToken !== undefined) {
817
+ const store = queueStore;
818
+ const token = claimToken;
819
+ // Renew the claim while the run executes. If renewal says the claim is
820
+ // gone, cancel so the fence trips on the next write instead of letting
821
+ // this runner interleave with the new claim holder.
822
+ run.renewTimer = setInterval(() => {
823
+ try {
824
+ if (
825
+ !store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
826
+ ) {
827
+ run.engine.cancel();
828
+ }
829
+ } catch {
830
+ // Transient store errors leave fencing to decide ownership.
831
+ }
832
+ }, RUN_CLAIM_RENEW_MS);
833
+ run.renewTimer.unref?.();
834
+ }
360
835
  clearWidgetTimer();
361
836
  startWidgetTicker(ctx, run);
362
- notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
837
+ if (!options.quiet) {
838
+ notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
839
+ }
363
840
 
364
- engine
365
- .run(workflow, input, { workflowPath: resolved.path })
841
+ run.completion = (
842
+ options.resume === true
843
+ ? engine.resumeRun(workflow, runId, { workflowHash })
844
+ : options.parentRunId === undefined
845
+ ? engine.run(workflow, input, { workflowPath: resolved.path, workflowHash, runId })
846
+ : engine.continueRun(workflow, options.parentRunId, input, {
847
+ workflowPath: resolved.path,
848
+ workflowHash,
849
+ runId,
850
+ })
851
+ )
366
852
  .then((result) => finishRun(ctx, run, result))
367
- .catch((error: unknown) => {
853
+ .catch(async (error: unknown) => {
368
854
  if (activeRun === run) {
369
855
  activeRun = null;
370
856
  }
857
+ const message = errorMessage(error);
858
+ // A resume that fails before executing (for example edited workflow
859
+ // source) must not strand the run: park it so it stays claimable
860
+ // once the cause is fixed, and say so in the feed.
861
+ let resumeFailed = run.resume === true && !isClaimLostError(error);
862
+ let terminalStatus: string | undefined;
863
+ // Re-parking only makes sense while the bundle is still resumable.
864
+ // A terminal or waiting bundle closes the queue row instead, or
865
+ // every session start would retry it forever — and the feed reports
866
+ // the bundle's real state, not a bogus failure.
867
+ if (resumeFailed) {
868
+ try {
869
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
870
+ if (bundle === null || bundle.state.status !== "running") {
871
+ resumeFailed = false;
872
+ terminalStatus = bundle?.state.status;
873
+ }
874
+ } catch {
875
+ // Unreadable bundles close the row too.
876
+ resumeFailed = false;
877
+ }
878
+ }
879
+ // A continuation that failed before its bundle exists frees the
880
+ // parent's continuation slot instead of consuming it forever.
881
+ let continuationSlotFreed = false;
882
+ if (
883
+ run.parentRunId !== undefined &&
884
+ run.claimToken !== undefined &&
885
+ !isClaimLostError(error)
886
+ ) {
887
+ try {
888
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
889
+ if (bundle === null) {
890
+ continuationSlotFreed =
891
+ runQueueStore?.deleteWorkflowRun({ runId, claimToken: run.claimToken }) === true;
892
+ if (continuationSlotFreed) {
893
+ run.claimToken = undefined;
894
+ }
895
+ }
896
+ } catch {
897
+ // The row may remain; a later cleanup pass can remove it.
898
+ }
899
+ }
900
+ releaseClaim(
901
+ run,
902
+ isClaimLostError(error) || continuationSlotFreed
903
+ ? "lost"
904
+ : resumeFailed
905
+ ? "park"
906
+ : "done",
907
+ );
908
+ void run.recorder?.stop();
371
909
  stopWidgetTicker();
372
910
  clearWidget(ctx);
373
- notify(ctx, `Workflow ${workflow.name} crashed: ${errorMessage(error)}`, "error");
911
+ if (isClaimLostError(error)) {
912
+ notify(ctx, `Workflow ${workflow.name} continues under another runner (run ${runId}).`);
913
+ return;
914
+ }
915
+ if (resumeFailed) {
916
+ recordRunEvent({ runId, workflowRef: workflow.name, type: "parked", payload: {} });
917
+ notify(
918
+ ctx,
919
+ `Could not resume workflow ${workflow.name}: ${message}. The run is parked again.`,
920
+ "warning",
921
+ );
922
+ return;
923
+ }
924
+ if (terminalStatus !== undefined) {
925
+ recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
926
+ notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
927
+ return;
928
+ }
929
+ recordRunEvent({
930
+ runId,
931
+ workflowRef: workflow.name,
932
+ type: "failed",
933
+ payload: { error: message },
934
+ });
935
+ try {
936
+ run.onFinish?.({ state: "failed", runId, error: message });
937
+ } catch {
938
+ // The original workflow failure remains the primary error.
939
+ }
940
+ notify(ctx, `Workflow ${workflow.name} crashed: ${message}`, "error");
941
+ });
942
+ return runId;
943
+ };
944
+
945
+ // Reclaim and resume a parked run when this session opens without an
946
+ // active run. The claim comes first; the engine resumes at the stopped
947
+ // node only after the queue proves ownership.
948
+ const resumeParkedRun = async (ctx: ExtensionContext): Promise<void> => {
949
+ if (activeRun !== null) {
950
+ return;
951
+ }
952
+ const queueStore = ensureRunQueueStore(ctx.cwd);
953
+ const claimToken = randomUUID();
954
+ const claimed = queueStore.claimNextWorkflowRun({
955
+ runnerId,
956
+ claimToken,
957
+ leaseMs: RUN_CLAIM_LEASE_MS,
958
+ });
959
+ if (claimed === undefined) {
960
+ return;
961
+ }
962
+ let started: string | undefined;
963
+ try {
964
+ started = await startRun(ctx, claimed.workflowPath, claimed.input, {
965
+ resume: true,
966
+ runId: claimed.runId,
967
+ claimToken,
374
968
  });
969
+ } catch (error) {
970
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
971
+ throw error;
972
+ }
973
+ if (started !== undefined) {
974
+ notify(ctx, `Resumed workflow run ${claimed.runId} (${claimed.workflowRef}).`);
975
+ } else {
976
+ queueStore.parkWorkflowRun({ runId: claimed.runId, claimToken });
977
+ }
978
+ };
979
+
980
+ const startChild: PiChildWorkflowStarter = async (request, signal, onComplete) => {
981
+ if (signal.aborted) {
982
+ throw signal.reason ?? new Error("Child workflow scheduling aborted");
983
+ }
984
+ const childKey = `${request.requestId}:${request.attempt}`;
985
+ if (activeRun !== null) {
986
+ if (activeRun.childKey !== childKey) {
987
+ return { state: "pending" };
988
+ }
989
+ return activeRun.lastState === null
990
+ ? { state: "running", runId: activeRun.runId }
991
+ : workflowSchedulerResult(activeRun.lastState);
992
+ }
993
+ const store = new WorkflowRunStore();
994
+ const bundle = await readRunBundle(store.runDirFor(request.runId));
995
+ if (bundle !== null) {
996
+ const recovered =
997
+ bundle.state.status === "running" ? await store.markRunInterrupted(request.runId) : bundle;
998
+ if (recovered !== null) {
999
+ const lastTraceEvent = await readLastTraceEvent(
1000
+ recovered.runDir,
1001
+ recovered.manifest.paths.trace,
1002
+ );
1003
+ return workflowSchedulerResult(recovered.state, lastTraceEvent?.type === "run_interrupted");
1004
+ }
1005
+ return { state: "pending" };
1006
+ }
1007
+ if (controllerContext === null) {
1008
+ return { state: "pending" };
1009
+ }
1010
+ await store.quarantineIncompleteRun(request.runId);
1011
+ const runId = await startRun(controllerContext, request.workflow, request.input, {
1012
+ runId: request.runId,
1013
+ childKey,
1014
+ onFinish: onComplete,
1015
+ presentation: false,
1016
+ quiet: true,
1017
+ signal,
1018
+ });
1019
+ return runId === undefined ? { state: "pending" } : { state: "running", runId };
1020
+ };
1021
+
1022
+ const updateControllerStatus = (ctx: ExtensionContext) => {
1023
+ if (controllerHost === undefined) {
1024
+ ctx.ui.setStatus("pi-controllers", undefined);
1025
+ return;
1026
+ }
1027
+ const count = controllerHost.store.listResources().length;
1028
+ ctx.ui.setStatus("pi-controllers", `${count} controller resource${count === 1 ? "" : "s"}`);
1029
+ };
1030
+
1031
+ const ensureControllerHost = async (
1032
+ ctx: ExtensionContext,
1033
+ ): Promise<PiControllerHost | undefined> => {
1034
+ controllerContext = ctx;
1035
+ if (controllerHost !== undefined) {
1036
+ return controllerHost;
1037
+ }
1038
+ controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
1039
+ controllerHost?.start();
1040
+ updateControllerStatus(ctx);
1041
+ return controllerHost;
375
1042
  };
376
1043
 
377
- const listWorkflows = async (ctx: ExtensionCommandContext) => {
1044
+ type WorkflowControlResult = {
1045
+ message: string;
1046
+ details: JsonObject;
1047
+ level?: "info" | "warning" | "error";
1048
+ };
1049
+
1050
+ const listWorkflowControl = async (
1051
+ ctx: ExtensionContext,
1052
+ offset = 0,
1053
+ ): Promise<WorkflowControlResult> => {
378
1054
  const discovered = await discoverWorkflows({ cwd: ctx.cwd });
379
1055
  if (discovered.length === 0) {
380
- notify(
381
- ctx,
382
- "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
383
- "warning",
1056
+ return {
1057
+ message:
1058
+ "No workflows found. Put *.workflow.ts files in .pi/workflows/ or ~/.pi/agent/workflows/, or pass a path.",
1059
+ details: { workflows: [], total: 0, offset: 0 },
1060
+ level: "warning",
1061
+ };
1062
+ }
1063
+ if (!Number.isInteger(offset) || offset < 0 || offset > discovered.length) {
1064
+ throw new Error(
1065
+ `Workflow list offset must be an integer from 0 through ${discovered.length}.`,
384
1066
  );
385
- return;
386
1067
  }
387
- const names = discovered.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
388
- notify(ctx, `Workflows: ${names}. Run one with /workflow <name> [task].`);
1068
+ const page = [];
1069
+ let nameChars = 0;
1070
+ for (const workflow of discovered.slice(offset)) {
1071
+ const renderedName = `${workflow.name} (${workflow.source})`;
1072
+ if (
1073
+ page.length >= MAX_WORKFLOW_LIST_ITEMS ||
1074
+ nameChars + renderedName.length > MAX_WORKFLOW_LIST_NAME_CHARS
1075
+ ) {
1076
+ break;
1077
+ }
1078
+ page.push(workflow);
1079
+ nameChars += renderedName.length;
1080
+ }
1081
+ const names = page.map((workflow) => `${workflow.name} (${workflow.source})`).join(", ");
1082
+ const nextOffset = offset + page.length;
1083
+ const omitted = discovered.length - nextOffset;
1084
+ return {
1085
+ message: [
1086
+ `Workflows: ${names}.`,
1087
+ omitted > 0 ? `${omitted} more omitted; list again with offset ${nextOffset}.` : "",
1088
+ "Run one with /workflow <name> [task].",
1089
+ ]
1090
+ .filter(Boolean)
1091
+ .join(" "),
1092
+ details: {
1093
+ workflows: page.map((workflow) => ({
1094
+ name: workflow.name,
1095
+ source: workflow.source,
1096
+ ...(workflow.name === "monitor"
1097
+ ? {
1098
+ description:
1099
+ "Repeatedly check a target, report requested changes, and stop on a condition.",
1100
+ }
1101
+ : {}),
1102
+ })),
1103
+ total: discovered.length,
1104
+ offset,
1105
+ omitted,
1106
+ ...(omitted > 0 ? { nextOffset } : {}),
1107
+ },
1108
+ };
1109
+ };
1110
+
1111
+ const listWorkflows = async (ctx: ExtensionContext) => {
1112
+ const result = await listWorkflowControl(ctx);
1113
+ notify(ctx, result.message, result.level);
1114
+ };
1115
+
1116
+ const cancelWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1117
+ if (activeRun) {
1118
+ const workflowName = activeRun.workflowName;
1119
+ const runId = activeRun.runId;
1120
+ activeRun.engine.cancel();
1121
+ return {
1122
+ message: `Cancelling workflow ${workflowName}…`,
1123
+ details: { action: "cancel", workflowName, runId },
1124
+ };
1125
+ }
1126
+ if (pendingToolLaunch !== null) {
1127
+ const ref = pendingToolLaunch.ref;
1128
+ pendingToolLaunch = null;
1129
+ return {
1130
+ message: `Cancelled the queued workflow launch for ${ref}.`,
1131
+ details: { action: "cancel", workflow: ref, queued: false },
1132
+ };
1133
+ }
1134
+ if (widgetSource) {
1135
+ const { state } = widgetSource;
1136
+ clearWidgetTimer();
1137
+ clearWidget(ctx);
1138
+ const detail =
1139
+ state.status === "waiting" && state.waitingOn
1140
+ ? `already ended at checkpoint ${state.waitingOn}`
1141
+ : `already ${state.status}`;
1142
+ return {
1143
+ message: `Workflow ${state.workflowName} ${detail}; cleared its widget.`,
1144
+ details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
1145
+ };
1146
+ }
1147
+ return {
1148
+ message: "No workflow is running.",
1149
+ details: { action: "cancel", active: false },
1150
+ level: "warning",
1151
+ };
1152
+ };
1153
+
1154
+ const pauseWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1155
+ if (!activeRun) {
1156
+ return {
1157
+ message: "No workflow is running.",
1158
+ details: { action: "pause", active: false },
1159
+ level: "warning",
1160
+ };
1161
+ }
1162
+ if (runHeld()) {
1163
+ return {
1164
+ message: `Workflow ${activeRun.workflowName} is already pausing or paused.`,
1165
+ details: {
1166
+ action: "pause",
1167
+ workflowName: activeRun.workflowName,
1168
+ runId: activeRun.runId,
1169
+ paused: true,
1170
+ },
1171
+ };
1172
+ }
1173
+ activeRun.engine.pause();
1174
+ renderWidget(ctx);
1175
+ return {
1176
+ message: `Pausing workflow ${activeRun.workflowName}; the current step will finish before the run holds.`,
1177
+ details: {
1178
+ action: "pause",
1179
+ workflowName: activeRun.workflowName,
1180
+ runId: activeRun.runId,
1181
+ paused: true,
1182
+ },
1183
+ };
1184
+ };
1185
+
1186
+ const resumeWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1187
+ if (!activeRun) {
1188
+ return {
1189
+ message: "No workflow is running.",
1190
+ details: { action: "resume", active: false },
1191
+ level: "warning",
1192
+ };
1193
+ }
1194
+ if (!runHeld()) {
1195
+ return {
1196
+ message: `Workflow ${activeRun.workflowName} is not paused.`,
1197
+ details: {
1198
+ action: "resume",
1199
+ workflowName: activeRun.workflowName,
1200
+ runId: activeRun.runId,
1201
+ paused: false,
1202
+ },
1203
+ level: "warning",
1204
+ };
1205
+ }
1206
+ activeRun.engine.resume();
1207
+ activeRun.executor.release();
1208
+ renderWidget(ctx);
1209
+ return {
1210
+ message: `Workflow ${activeRun.workflowName} resumed.`,
1211
+ details: {
1212
+ action: "resume",
1213
+ workflowName: activeRun.workflowName,
1214
+ runId: activeRun.runId,
1215
+ paused: false,
1216
+ },
1217
+ };
1218
+ };
1219
+
1220
+ const statusWorkflowControl = async (
1221
+ ctx: ExtensionContext,
1222
+ runId?: string,
1223
+ ): Promise<WorkflowControlResult> => {
1224
+ if (runId !== undefined) {
1225
+ const bundle = await readRunBundle(new WorkflowRunStore().runDirFor(runId));
1226
+ if (bundle === null) {
1227
+ throw new Error(`Workflow run not found: ${runId}`);
1228
+ }
1229
+ const { state } = bundle;
1230
+ return {
1231
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1232
+ details: workflowStateSummary(state),
1233
+ };
1234
+ }
1235
+ const state = activeRun?.lastState ?? widgetSource?.state;
1236
+ if ((state === undefined || state === null) && pendingToolLaunch !== null) {
1237
+ return {
1238
+ message: `Workflow ${pendingToolLaunch.ref} is queued until the current turn finishes.`,
1239
+ details: { active: false, queued: true, workflow: pendingToolLaunch.ref },
1240
+ };
1241
+ }
1242
+ if (state === undefined || state === null) {
1243
+ return {
1244
+ message: "No workflow run is active or displayed.",
1245
+ details: { active: false },
1246
+ level: "warning",
1247
+ };
1248
+ }
1249
+ return {
1250
+ message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1251
+ details: workflowStateSummary(state),
1252
+ };
1253
+ };
1254
+
1255
+ const resolveWaitingWorkflow = async (
1256
+ ctx: ExtensionContext,
1257
+ requestedRunId?: string,
1258
+ ): Promise<{ parentRunId: string; workflowPath: string }> => {
1259
+ let parentRunId = requestedRunId ?? lastWaitingRunId;
1260
+ if (parentRunId === null) {
1261
+ const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1262
+ const known = new Set(rows.map((row) => row.runId));
1263
+ const continued = new Set(
1264
+ rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1265
+ );
1266
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1267
+ parentRunId =
1268
+ bundles.find(
1269
+ (bundle) =>
1270
+ bundle.state.status === "waiting" &&
1271
+ known.has(bundle.state.runId) &&
1272
+ !continued.has(bundle.state.runId),
1273
+ )?.state.runId ?? null;
1274
+ }
1275
+ if (parentRunId === null) {
1276
+ throw new Error("No workflow is waiting for an answer.");
1277
+ }
1278
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1279
+ if (
1280
+ parent === null ||
1281
+ parent.state.status !== "waiting" ||
1282
+ parent.state.workflowPath === undefined
1283
+ ) {
1284
+ if (parentRunId === lastWaitingRunId) {
1285
+ lastWaitingRunId = null;
1286
+ }
1287
+ throw new Error(`Workflow run ${parentRunId} is no longer waiting.`);
1288
+ }
1289
+ return { parentRunId, workflowPath: parent.state.workflowPath };
1290
+ };
1291
+
1292
+ const answerWorkflowControl = async (
1293
+ ctx: ExtensionContext,
1294
+ input: unknown,
1295
+ requestedRunId?: string,
1296
+ ): Promise<WorkflowControlResult> => {
1297
+ const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1298
+ const continued = await startRun(ctx, waiting.workflowPath, input, {
1299
+ parentRunId: waiting.parentRunId,
1300
+ });
1301
+ if (continued === undefined) {
1302
+ throw new Error("Could not start the checkpoint continuation.");
1303
+ }
1304
+ lastWaitingRunId = null;
1305
+ return {
1306
+ message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
1307
+ details: {
1308
+ action: "answer",
1309
+ parentRunId: waiting.parentRunId,
1310
+ runId: continued,
1311
+ },
1312
+ };
1313
+ };
1314
+
1315
+ const startWorkflowControl = async (
1316
+ ctx: ExtensionContext,
1317
+ ref: string,
1318
+ input: unknown,
1319
+ ): Promise<WorkflowControlResult> => {
1320
+ if (activeRun !== null) {
1321
+ throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
1322
+ }
1323
+ if (pendingToolLaunch !== null) {
1324
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1325
+ }
1326
+ if (presentationPending !== null) {
1327
+ throw new Error("The previous workflow result is still being presented.");
1328
+ }
1329
+ const runId = await startRun(ctx, ref, input);
1330
+ if (runId === undefined) {
1331
+ throw new Error("The workflow could not start.");
1332
+ }
1333
+ return {
1334
+ message: `Workflow ${ref} started (run ${runId}).`,
1335
+ details: { action: "start", workflow: ref, runId },
1336
+ };
1337
+ };
1338
+
1339
+ const queueToolLaunch = async (
1340
+ ctx: ExtensionContext,
1341
+ ref: string,
1342
+ input: unknown,
1343
+ options: StartRunOptions = {},
1344
+ ): Promise<WorkflowControlResult> => {
1345
+ if (activeRun !== null) {
1346
+ throw new Error(
1347
+ `A workflow is already running: ${activeRun.workflowName}. Cancel it before starting another.`,
1348
+ );
1349
+ }
1350
+ if (pendingToolLaunch !== null) {
1351
+ throw new Error("A workflow launch is already waiting for the current turn to finish.");
1352
+ }
1353
+ if (presentationPending !== null) {
1354
+ throw new Error("The previous workflow result is still being presented.");
1355
+ }
1356
+ const reservation = { ctx, ref, input, options };
1357
+ pendingToolLaunch = reservation;
1358
+ try {
1359
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd });
1360
+ const workflow = await loadWorkflowFile(resolved.path);
1361
+ if (pendingToolLaunch !== reservation) {
1362
+ throw new Error("The queued workflow launch was cancelled before validation finished.");
1363
+ }
1364
+ return {
1365
+ message: `Workflow ${workflow.name} will start after this turn finishes.`,
1366
+ details: {
1367
+ action: "start",
1368
+ workflow: workflow.name,
1369
+ source: resolved.source,
1370
+ queued: true,
1371
+ },
1372
+ };
1373
+ } catch (error) {
1374
+ if (pendingToolLaunch === reservation) {
1375
+ pendingToolLaunch = null;
1376
+ }
1377
+ throw error;
1378
+ }
389
1379
  };
390
1380
 
391
1381
  pi.registerCommand("workflow", {
392
1382
  description:
393
- "Run a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: pause, resume, cancel",
1383
+ "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
394
1384
  getArgumentCompletions: async (prefix: string) => {
395
1385
  const discovered = await discoverWorkflows({ cwd: process.cwd() });
396
1386
  const items = [
397
1387
  ...discovered.map((workflow) => ({ value: workflow.name, label: workflow.name })),
1388
+ { value: "status", label: "status" },
398
1389
  { value: "pause", label: "pause" },
399
1390
  { value: "resume", label: "resume" },
400
1391
  { value: "cancel", label: "cancel" },
1392
+ { value: "answer", label: "answer" },
401
1393
  ].filter((item) => item.value.startsWith(prefix));
402
1394
  return items.length > 0 ? items : null;
403
1395
  },
@@ -414,93 +1406,186 @@ export default function piWorkflows(pi: ExtensionAPI) {
414
1406
  return;
415
1407
  }
416
1408
  if (parsed.kind === "cancel") {
417
- if (activeRun) {
418
- activeRun.engine.cancel();
419
- notify(ctx, `Cancelling workflow ${activeRun.workflowName}…`);
420
- return;
421
- }
422
- // No live run, but a parked (waiting) or recently finished run may
423
- // still occupy the widget; cancel clears it.
424
- if (widgetSource) {
425
- const { state } = widgetSource;
426
- clearWidgetTimer();
427
- clearWidget(ctx);
428
- const detail =
429
- state.status === "waiting" && state.waitingOn
430
- ? `already ended at checkpoint ${state.waitingOn}`
431
- : `already ${state.status}`;
432
- notify(ctx, `Workflow ${state.workflowName} ${detail}; cleared its widget.`);
433
- return;
434
- }
435
- notify(ctx, "No workflow is running.", "warning");
1409
+ const result = cancelWorkflowControl(ctx);
1410
+ notify(ctx, result.message, result.level);
436
1411
  return;
437
1412
  }
438
1413
  if (parsed.kind === "pause") {
439
- if (!activeRun) {
440
- notify(ctx, "No workflow is running.", "warning");
441
- return;
442
- }
443
- if (runHeld()) {
444
- notify(ctx, `Workflow ${activeRun.workflowName} is already pausing or paused.`);
445
- return;
446
- }
447
- activeRun.engine.pause();
448
- renderWidget(ctx);
449
- notify(
450
- ctx,
451
- `Pausing workflow ${activeRun.workflowName} — the current step finishes, then the run holds. /workflow resume to continue.`,
452
- );
1414
+ const result = pauseWorkflowControl(ctx);
1415
+ notify(ctx, result.message, result.level);
453
1416
  return;
454
1417
  }
455
1418
  if (parsed.kind === "resume") {
456
- if (!activeRun) {
457
- notify(ctx, "No workflow is running.", "warning");
458
- return;
1419
+ const result = resumeWorkflowControl(ctx);
1420
+ notify(ctx, result.message, result.level);
1421
+ return;
1422
+ }
1423
+ if (parsed.kind === "status") {
1424
+ try {
1425
+ const result = await statusWorkflowControl(ctx, parsed.runId);
1426
+ notify(ctx, result.message, result.level);
1427
+ } catch (error) {
1428
+ notify(ctx, errorMessage(error), "error");
459
1429
  }
460
- if (!runHeld()) {
461
- notify(ctx, `Workflow ${activeRun.workflowName} is not paused.`);
462
- return;
1430
+ return;
1431
+ }
1432
+ if (parsed.kind === "answer") {
1433
+ try {
1434
+ const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
1435
+ notify(ctx, result.message, result.level);
1436
+ } catch (error) {
1437
+ const message = errorMessage(error);
1438
+ notify(
1439
+ ctx,
1440
+ /workflow_run_queue_parent/.test(message)
1441
+ ? "That checkpoint was already answered; see its continuation run."
1442
+ : `Could not continue workflow: ${message}`,
1443
+ "error",
1444
+ );
463
1445
  }
464
- activeRun.engine.resume();
465
- activeRun.executor.release();
466
- renderWidget(ctx);
467
- notify(ctx, `Workflow ${activeRun.workflowName} resumed.`);
468
1446
  return;
469
1447
  }
470
1448
  try {
471
- await startRun(ctx, parsed.ref, parsed.input);
1449
+ const result = await startWorkflowControl(ctx, parsed.ref, parsed.input);
1450
+ notify(ctx, result.message, result.level);
472
1451
  } catch (error) {
473
1452
  notify(ctx, `Could not start workflow: ${errorMessage(error)}`, "error");
474
1453
  }
475
1454
  },
476
1455
  });
477
1456
 
1457
+ pi.registerCommand("controller", {
1458
+ description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
1459
+ getArgumentCompletions: async (prefix: string) => {
1460
+ const items = ["list", "get", "apply", "reconcile", "delete", "start", "stop"]
1461
+ .filter((value) => value.startsWith(prefix))
1462
+ .map((value) => ({ value, label: value }));
1463
+ return items.length > 0 ? items : null;
1464
+ },
1465
+ handler: async (args, ctx) => {
1466
+ let parsed;
1467
+ try {
1468
+ parsed = parseControllerArgs(args);
1469
+ } catch (error) {
1470
+ notify(ctx, errorMessage(error), "error");
1471
+ return;
1472
+ }
1473
+ const host = await ensureControllerHost(ctx);
1474
+ if (host === undefined) {
1475
+ notify(
1476
+ ctx,
1477
+ "No controllers found. Put *.controller.ts files in .pi/controllers/ or ~/.pi/agent/controllers/.",
1478
+ "warning",
1479
+ );
1480
+ return;
1481
+ }
1482
+ try {
1483
+ switch (parsed.kind) {
1484
+ case "list":
1485
+ notify(ctx, host.list());
1486
+ break;
1487
+ case "get":
1488
+ notify(ctx, host.get(parsed.controller, parsed.key));
1489
+ break;
1490
+ case "apply": {
1491
+ const resource = host.apply(parsed.controller, parsed.key, parsed.spec);
1492
+ updateControllerStatus(ctx);
1493
+ notify(
1494
+ ctx,
1495
+ `Applied ${resource.metadata.controller}/${resource.metadata.key} generation ${resource.metadata.generation}.`,
1496
+ );
1497
+ break;
1498
+ }
1499
+ case "reconcile":
1500
+ host.reconcile(parsed.controller, parsed.key);
1501
+ notify(ctx, `Queued ${parsed.controller}/${parsed.key}.`);
1502
+ break;
1503
+ case "delete":
1504
+ host.delete(parsed.controller, parsed.key);
1505
+ notify(ctx, `Requested deletion of ${parsed.controller}/${parsed.key}.`);
1506
+ break;
1507
+ case "start":
1508
+ host.start();
1509
+ notify(ctx, "Controller workers started.");
1510
+ break;
1511
+ case "stop":
1512
+ if (activeRun?.childKey !== undefined) {
1513
+ activeRun.interruptionRequested = true;
1514
+ activeRun.engine.cancel();
1515
+ }
1516
+ await host.stop();
1517
+ notify(ctx, "Controller workers stopped.");
1518
+ break;
1519
+ }
1520
+ } catch (error) {
1521
+ notify(ctx, `Controller command failed: ${errorMessage(error)}`, "error");
1522
+ }
1523
+ },
1524
+ });
1525
+
478
1526
  pi.registerTool({
479
1527
  name: "workflow",
480
1528
  label: "Workflow",
481
1529
  description: [
482
- "Submit the output for the pending workflow step.",
483
- "Only call this tool when a workflow step contract in the conversation asks you to.",
484
- "Pass the exact step id from the contract and your result as the output.",
1530
+ "List, start, inspect, pause, resume, cancel, answer, or complete Pi Workflows runs.",
1531
+ "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, reportWhen, stopWhen, and optional maxChecks.",
1532
+ "Use submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
1533
+ "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
485
1534
  ].join(" "),
486
- parameters: Type.Object({
487
- step: Type.String({ description: "The step id from the workflow step contract" }),
488
- attempt: Type.String({ description: "The attempt id from the workflow step contract" }),
489
- output: Type.Unknown({ description: "The step output, matching the expected output shape" }),
490
- }),
491
- async execute(_toolCallId, params) {
492
- if (!activeRun) {
493
- throw new Error(
494
- "No workflow is running. Do not call the workflow tool outside a workflow.",
495
- );
496
- }
497
- const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
498
- if (!result.accepted) {
499
- throw new Error(result.message);
1535
+ parameters: WorkflowToolParameters,
1536
+ async execute(_toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
1537
+ let control: WorkflowControlResult;
1538
+ switch (params.action) {
1539
+ case "list":
1540
+ control = await listWorkflowControl(ctx, params.offset);
1541
+ break;
1542
+ case "start":
1543
+ control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
1544
+ break;
1545
+ case "status":
1546
+ control = await statusWorkflowControl(ctx, params.runId);
1547
+ break;
1548
+ case "pause":
1549
+ control = pauseWorkflowControl(ctx);
1550
+ break;
1551
+ case "resume":
1552
+ control = resumeWorkflowControl(ctx);
1553
+ break;
1554
+ case "cancel":
1555
+ control = cancelWorkflowControl(ctx);
1556
+ break;
1557
+ case "answer": {
1558
+ const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1559
+ control = await queueToolLaunch(ctx, waiting.workflowPath, params.input, {
1560
+ parentRunId: waiting.parentRunId,
1561
+ });
1562
+ break;
1563
+ }
1564
+ case "submit": {
1565
+ if (!activeRun) {
1566
+ throw new Error("No workflow step is waiting for output.");
1567
+ }
1568
+ // Flush the conversation into the bundle before accepting, so the
1569
+ // attempt range includes the assistant message carrying this call.
1570
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
1571
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
1572
+ const result = await activeRun.executor.submit(
1573
+ params.step,
1574
+ params.attempt,
1575
+ params.output,
1576
+ );
1577
+ if (!result.accepted) {
1578
+ throw new Error(result.message);
1579
+ }
1580
+ return {
1581
+ content: [{ type: "text", text: result.message }],
1582
+ details: { action: "submit", step: params.step, accepted: true },
1583
+ };
1584
+ }
500
1585
  }
501
1586
  return {
502
- content: [{ type: "text", text: result.message }],
503
- details: { step: params.step, accepted: true },
1587
+ content: [{ type: "text", text: control.message }],
1588
+ details: control.details,
504
1589
  };
505
1590
  },
506
1591
  });
@@ -515,6 +1600,27 @@ export default function piWorkflows(pi: ExtensionAPI) {
515
1600
  handler: (ctx) => scrollWidget(ctx, WIDGET_SCROLL_STEP),
516
1601
  });
517
1602
 
1603
+ pi.on("session_start", async (_event, ctx) => {
1604
+ sessionClosed = false;
1605
+ controllerContext = ctx;
1606
+ try {
1607
+ syncArmed = true;
1608
+ startRunSync(ctx);
1609
+ } catch {
1610
+ // Session sync is best-effort; runs themselves never depend on it.
1611
+ }
1612
+ try {
1613
+ await resumeParkedRun(ctx);
1614
+ } catch (error) {
1615
+ notify(ctx, `Could not resume a parked workflow: ${errorMessage(error)}`, "warning");
1616
+ }
1617
+ try {
1618
+ await ensureControllerHost(ctx);
1619
+ } catch (error) {
1620
+ notify(ctx, `Could not start controller workers: ${errorMessage(error)}`, "warning");
1621
+ }
1622
+ });
1623
+
518
1624
  pi.on("agent_start", () => {
519
1625
  if (!activeRun && presentationPending === null && presentationAbort) {
520
1626
  // A normal user turn started while an async presentation prompt was
@@ -552,20 +1658,91 @@ export default function piWorkflows(pi: ExtensionAPI) {
552
1658
  );
553
1659
  });
554
1660
 
555
- pi.on("agent_settled", () => {
556
- if (!activeRun) {
1661
+ pi.on("turn_start", (event) => {
1662
+ activeRun?.recorder?.handleTurnStart(event);
1663
+ });
1664
+
1665
+ pi.on("turn_end", async (event, ctx) => {
1666
+ await activeRun?.recorder?.handleTurnEnd(event, ctx).catch(() => undefined);
1667
+ });
1668
+
1669
+ pi.on("message_start", async (event, ctx) => {
1670
+ await activeRun?.recorder?.handleMessageStart(event, ctx).catch(() => undefined);
1671
+ });
1672
+
1673
+ pi.on("message_update", (event) => {
1674
+ activeRun?.recorder?.handleMessageUpdate(event);
1675
+ });
1676
+
1677
+ pi.on("message_end", (event) => {
1678
+ activeRun?.recorder?.handleMessageEnd(event);
1679
+ });
1680
+
1681
+ pi.on("tool_execution_start", (event) => {
1682
+ activeRun?.recorder?.handleToolStart(event);
1683
+ });
1684
+
1685
+ pi.on("tool_execution_update", (event) => {
1686
+ activeRun?.recorder?.handleToolUpdate(event);
1687
+ });
1688
+
1689
+ pi.on("tool_execution_end", (event) => {
1690
+ activeRun?.recorder?.handleToolEnd(event);
1691
+ });
1692
+
1693
+ pi.on("agent_settled", async (_event, ctx) => {
1694
+ if (activeRun === null && pendingToolLaunch !== null) {
1695
+ const launch = pendingToolLaunch;
1696
+ pendingToolLaunch = null;
1697
+ try {
1698
+ const runId = await startRun(launch.ctx, launch.ref, launch.input, launch.options);
1699
+ if (runId === undefined) {
1700
+ notify(launch.ctx, "The queued workflow could not start.", "error");
1701
+ } else if (launch.options?.parentRunId !== undefined) {
1702
+ lastWaitingRunId = null;
1703
+ }
1704
+ } catch (error) {
1705
+ notify(launch.ctx, `Could not start queued workflow: ${errorMessage(error)}`, "error");
1706
+ }
1707
+ return;
1708
+ }
1709
+ const run = activeRun;
1710
+ if (!run) {
557
1711
  presentationPending = null;
558
1712
  return;
559
1713
  }
560
- activeRun.executor.setStreaming(false);
561
- activeRun.executor.handleAgentSettled();
1714
+ await run.recorder?.synchronize(ctx).catch(() => undefined);
1715
+ run.recorder?.settleAttempt();
1716
+ run.executor.setStreaming(false);
1717
+ run.executor.handleAgentSettled();
562
1718
  });
563
1719
 
564
- pi.on("session_shutdown", () => {
1720
+ pi.on("session_shutdown", async () => {
565
1721
  sessionClosed = true;
566
1722
  supersedePresentation();
567
- activeRun?.engine.cancel();
1723
+ const run = activeRun;
1724
+ if (run !== null && run.claimToken !== undefined) {
1725
+ // Queued interactive runs park: no terminal event, no recorded partial
1726
+ // attempt, and the claim releases so another runner can resume.
1727
+ run.engine.park();
1728
+ } else {
1729
+ run?.engine.cancel();
1730
+ }
1731
+ await run?.recorder?.stop().catch(() => undefined);
1732
+ await run?.completion?.catch(() => undefined);
568
1733
  activeRun = null;
1734
+ pendingToolLaunch = null;
1735
+ lastWaitingRunId = null;
1736
+ if (runSyncTimer !== null) {
1737
+ clearInterval(runSyncTimer);
1738
+ runSyncTimer = null;
1739
+ }
1740
+ syncArmed = false;
1741
+ await controllerHost?.close().catch(() => undefined);
1742
+ controllerHost = undefined;
1743
+ controllerContext = null;
1744
+ runQueueStore?.close();
1745
+ runQueueStore = null;
569
1746
  presentationPending = null;
570
1747
  clearWidgetTimer();
571
1748
  stopWidgetTicker();
@@ -574,6 +1751,35 @@ export default function piWorkflows(pi: ExtensionAPI) {
574
1751
  });
575
1752
  }
576
1753
 
1754
+ function workflowSchedulerResult(
1755
+ state: WorkflowRunState,
1756
+ interrupted = false,
1757
+ ): WorkflowSchedulerResult {
1758
+ if (interrupted) {
1759
+ return {
1760
+ state: "interrupted",
1761
+ runId: state.runId,
1762
+ ...(state.error !== undefined ? { error: state.error } : {}),
1763
+ };
1764
+ }
1765
+ switch (state.status) {
1766
+ case "running":
1767
+ return { state: "running", runId: state.runId };
1768
+ case "waiting":
1769
+ return { state: "waiting", runId: state.runId };
1770
+ case "completed":
1771
+ return { state: "succeeded", runId: state.runId };
1772
+ case "failed":
1773
+ case "timed_out":
1774
+ case "cancelled":
1775
+ return {
1776
+ state: "failed",
1777
+ runId: state.runId,
1778
+ ...(state.error !== undefined ? { error: state.error } : {}),
1779
+ };
1780
+ }
1781
+ }
1782
+
577
1783
  async function resolvePresentationPrompt(
578
1784
  buildPrompt: PresentationPromptBuilder,
579
1785
  state: WorkflowRunState,