@osolmaz/pi-workflows 0.15.3 → 0.16.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 (118) hide show
  1. package/README.md +6 -6
  2. package/dist/client/activity.d.ts +2 -0
  3. package/dist/client/activity.js +6 -0
  4. package/dist/client/activity.js.map +1 -0
  5. package/dist/client/client.d.ts +101 -0
  6. package/dist/client/client.js +733 -0
  7. package/dist/client/client.js.map +1 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/materialize.d.ts +7 -0
  12. package/dist/client/materialize.js +177 -0
  13. package/dist/client/materialize.js.map +1 -0
  14. package/dist/client/protocol.d.ts +60 -0
  15. package/dist/client/protocol.js +269 -0
  16. package/dist/client/protocol.js.map +1 -0
  17. package/dist/client/resolver.d.ts +23 -0
  18. package/dist/client/resolver.js +2 -0
  19. package/dist/client/resolver.js.map +1 -0
  20. package/dist/client/view.d.ts +118 -0
  21. package/dist/client/view.js +3 -0
  22. package/dist/client/view.js.map +1 -0
  23. package/dist/controllers/sqlite.d.ts +43 -0
  24. package/dist/controllers/sqlite.js +137 -2
  25. package/dist/controllers/sqlite.js.map +1 -1
  26. package/dist/extension/index.d.ts +1 -0
  27. package/dist/extension/index.js +278 -183
  28. package/dist/extension/index.js.map +1 -1
  29. package/dist/extension/session-view.d.ts +7 -2
  30. package/dist/extension/session-view.js +60 -52
  31. package/dist/extension/session-view.js.map +1 -1
  32. package/dist/extension/widget.d.ts +2 -1
  33. package/dist/extension/widget.js +14 -7
  34. package/dist/extension/widget.js.map +1 -1
  35. package/dist/host/child-worker-supervisor.js +1 -1
  36. package/dist/host/child-worker-supervisor.js.map +1 -1
  37. package/dist/host/resolver-entry.d.ts +2 -23
  38. package/dist/host/resolver-entry.js +1 -1
  39. package/dist/host/resolver-entry.js.map +1 -1
  40. package/dist/host/runner.d.ts +11 -0
  41. package/dist/host/runner.js +473 -17
  42. package/dist/host/runner.js.map +1 -1
  43. package/dist/host/state.d.ts +6 -3
  44. package/dist/host/state.js +52 -26
  45. package/dist/host/state.js.map +1 -1
  46. package/dist/host/view.d.ts +73 -0
  47. package/dist/host/view.js +871 -0
  48. package/dist/host/view.js.map +1 -0
  49. package/dist/host/worker-protocol.js +1 -1
  50. package/dist/host/worker-protocol.js.map +1 -1
  51. package/dist/state/database.d.ts +1 -0
  52. package/dist/state/database.js +15 -0
  53. package/dist/state/database.js.map +1 -1
  54. package/dist/state/prune.d.ts +3 -1
  55. package/dist/state/prune.js +6 -8
  56. package/dist/state/prune.js.map +1 -1
  57. package/dist/state/schema.js +12 -1
  58. package/dist/state/schema.js.map +1 -1
  59. package/dist/viewer/backup.d.ts +2 -0
  60. package/dist/viewer/backup.js +28 -0
  61. package/dist/viewer/backup.js.map +1 -0
  62. package/dist/viewer/cli.d.ts +4 -0
  63. package/dist/viewer/cli.js +150 -170
  64. package/dist/viewer/cli.js.map +1 -1
  65. package/dist/viewer/tui.d.ts +5 -7
  66. package/dist/viewer/tui.js +188 -108
  67. package/dist/viewer/tui.js.map +1 -1
  68. package/dist/workflows/store.d.ts +62 -1
  69. package/dist/workflows/store.js +350 -44
  70. package/dist/workflows/store.js.map +1 -1
  71. package/docs/2026-09-01-unified-workflow-client-plan.md +381 -0
  72. package/docs/2026-09-02-installed-live-e2e-plan.md +225 -0
  73. package/docs/SQLITE_STATE.md +13 -11
  74. package/docs/WORKFLOW_HOST.md +74 -61
  75. package/docs/development.md +2 -1
  76. package/docs/live-replay-protocol.md +70 -132
  77. package/docs/tui-viewer.md +10 -14
  78. package/docs/workflows.md +44 -1
  79. package/herdr-plugin.toml +1 -1
  80. package/package.json +9 -3
  81. package/protocol/client.v1.schema.json +137 -0
  82. package/protocol/fixtures/client-v1.json +23 -0
  83. package/src/client/activity.ts +6 -0
  84. package/src/client/client.ts +935 -0
  85. package/src/client/index.ts +24 -0
  86. package/src/client/materialize.ts +228 -0
  87. package/src/client/protocol.ts +327 -0
  88. package/src/client/resolver.ts +26 -0
  89. package/src/client/view.ts +138 -0
  90. package/src/controllers/sqlite.ts +213 -2
  91. package/src/extension/index.ts +349 -208
  92. package/src/extension/session-view.ts +73 -50
  93. package/src/extension/widget.ts +16 -8
  94. package/src/host/child-worker-supervisor.ts +1 -1
  95. package/src/host/resolver-entry.ts +11 -26
  96. package/src/host/runner.ts +648 -48
  97. package/src/host/state.ts +71 -43
  98. package/src/host/view.ts +1084 -0
  99. package/src/host/worker-protocol.ts +1 -1
  100. package/src/state/database.ts +13 -0
  101. package/src/state/prune.ts +11 -11
  102. package/src/state/schema.ts +12 -1
  103. package/src/viewer/backup.ts +29 -0
  104. package/src/viewer/cli.ts +171 -185
  105. package/src/viewer/tui.ts +196 -124
  106. package/src/workflows/store.ts +500 -45
  107. package/dist/host/client.d.ts +0 -48
  108. package/dist/host/client.js +0 -216
  109. package/dist/host/client.js.map +0 -1
  110. package/dist/host/protocol.d.ts +0 -38
  111. package/dist/host/protocol.js +0 -156
  112. package/dist/host/protocol.js.map +0 -1
  113. package/dist/viewer/watch.d.ts +0 -6
  114. package/dist/viewer/watch.js +0 -46
  115. package/dist/viewer/watch.js.map +0 -1
  116. package/src/host/client.ts +0 -293
  117. package/src/host/protocol.ts +0 -196
  118. package/src/viewer/watch.ts +0 -51
@@ -2,17 +2,28 @@ import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { BUILTIN_WORKFLOW_METADATA } from "../builtins/metadata.js";
5
- import { SqliteControllerStore, type WorkflowRunQueueRecord } from "../controllers/sqlite.js";
6
- import { WorkflowHostClient } from "../host/client.js";
7
- import type { HostResponse } from "../host/protocol.js";
8
- import { HostStateStore, type InteractiveRequestRecord } from "../host/state.js";
9
- import { workflowStatePath } from "../state/database.js";
5
+ import { ORIGIN_ACTIVITY_REFRESH_MS } from "../client/activity.js";
6
+ import { WorkflowClient } from "../client/client.js";
7
+ import { materializeSessionView } from "../client/materialize.js";
8
+ import type { ClientResponse } from "../client/protocol.js";
9
+ import type {
10
+ ClientInteractiveRequest,
11
+ WorkflowRunQueueView,
12
+ WorkflowSessionView,
13
+ } from "../client/view.js";
10
14
  import { canonicalJson, parseJson, type JsonValue } from "../state/json.js";
11
15
  import { errorMessage } from "../workflows/errors.js";
12
16
  import { discoverWorkflows } from "../workflows/loader.js";
13
- import { createRunId, WorkflowRunStore } from "../workflows/store.js";
17
+ import { createRunId } from "../workflows/store.js";
14
18
  import type { AgentStepContract, HumanDecisionResponse } from "../workflows/types.js";
15
19
  import { parseControllerArgs, type ParsedControllerArgs } from "./controller-command.js";
20
+ import {
21
+ HerdrWorkflowViewer,
22
+ PIW_SHORTCUT,
23
+ PIW_SHORTCUT_HINT,
24
+ VIEWER_PLACEMENTS,
25
+ type ViewerPlacement,
26
+ } from "./herdr-viewer.js";
16
27
  import { SessionDeliveryCoordinator, type ClaimedSessionDelivery } from "./session-delivery.js";
17
28
  import { SessionWorkflowView } from "./session-view.js";
18
29
  import {
@@ -47,10 +58,26 @@ export {
47
58
  } from "./decision-channels.js";
48
59
 
49
60
  const INTERACTION_POLL_MS = 1_000;
50
- const SUBMISSION_POLL_MS = 50;
51
61
  const WORKFLOW_INTERACTION_MESSAGE_TYPE = "pi-workflows-interaction";
52
62
  const WORKFLOW_NOTIFICATION_MESSAGE_TYPE = "pi-workflows-notification";
53
63
  const WORKFLOW_PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
64
+ const sessionSnapshots = new Map<string, WorkflowSessionView>();
65
+
66
+ type PresentedOriginActivity = {
67
+ sessionId: string;
68
+ runId: string;
69
+ requestId: string;
70
+ deliveryId: string;
71
+ sessionEntryId: string;
72
+ };
73
+
74
+ export function activityStateForConnection(
75
+ state: "started" | "refresh" | "settled",
76
+ previousConnectionId: string | null,
77
+ connectionId: string,
78
+ ): "started" | "refresh" | "settled" {
79
+ return state === "refresh" && previousConnectionId !== connectionId ? "started" : state;
80
+ }
54
81
 
55
82
  export type ParsedWorkflowArgs =
56
83
  | { kind: "list" }
@@ -128,14 +155,65 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
128
155
 
129
156
  export default function piWorkflows(pi: ExtensionAPI): void {
130
157
  registerWorkflowAgentStepMessageRenderer(pi);
131
- const client = new WorkflowHostClient({ clientId: `pi-extension-${randomUUID()}` });
158
+ let client = new WorkflowClient({ clientId: `pi-extension-${randomUUID()}` });
159
+ const herdrViewer = new HerdrWorkflowViewer(pi.exec);
132
160
  let sessionContext: ExtensionContext | null = null;
161
+ let sessionGeneration = 0;
162
+ let sessionUnsubscribe: (() => Promise<void>) | null = null;
133
163
  let pollTimer: ReturnType<typeof setInterval> | null = null;
134
164
  let presentationTail = Promise.resolve();
135
165
  let toolTail = Promise.resolve();
166
+ let originActivity: (PresentedOriginActivity & { sequence: number }) | null = null;
167
+ let originActivityConnectionId: string | null = null;
168
+ let activityTimer: ReturnType<typeof setInterval> | null = null;
136
169
  const sessionDelivery = new SessionDeliveryCoordinator();
137
170
  const sessionView = new SessionWorkflowView();
138
171
 
172
+ const reportOriginActivity = async (
173
+ activity: PresentedOriginActivity & { sequence: number },
174
+ state: "started" | "refresh" | "settled",
175
+ ): Promise<void> => {
176
+ const connection = await client.connect();
177
+ const reportState = activityStateForConnection(
178
+ state,
179
+ originActivityConnectionId,
180
+ connection.connectionId,
181
+ );
182
+ const response = await client.request({
183
+ operation: "activity.report",
184
+ runId: activity.runId,
185
+ payload: { ...activity, state: reportState },
186
+ });
187
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
188
+ throw new Error(response.error ?? "Workflow host rejected origin activity");
189
+ }
190
+ originActivityConnectionId =
191
+ reportState === "settled" ? null : (client.connectionId ?? connection.connectionId);
192
+ };
193
+
194
+ const startOriginActivity = (presented: PresentedOriginActivity): void => {
195
+ if (activityTimer !== null) clearInterval(activityTimer);
196
+ originActivityConnectionId = null;
197
+ originActivity = { ...presented, sequence: 0 };
198
+ void reportOriginActivity(originActivity, "started").catch(() => undefined);
199
+ activityTimer = setInterval(() => {
200
+ if (originActivity === null) return;
201
+ originActivity.sequence += 1;
202
+ void reportOriginActivity(originActivity, "refresh").catch(() => undefined);
203
+ }, ORIGIN_ACTIVITY_REFRESH_MS);
204
+ activityTimer.unref?.();
205
+ };
206
+
207
+ const settleOriginActivity = async (): Promise<void> => {
208
+ if (activityTimer !== null) clearInterval(activityTimer);
209
+ activityTimer = null;
210
+ const settled = originActivity;
211
+ originActivity = null;
212
+ if (settled === null) return;
213
+ settled.sequence += 1;
214
+ await reportOriginActivity(settled, "settled").catch(() => undefined);
215
+ };
216
+
139
217
  const presentInOrder = async (ctx: ExtensionContext): Promise<void> => {
140
218
  const prior = presentationTail;
141
219
  let release: (() => void) | undefined;
@@ -144,10 +222,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
144
222
  });
145
223
  await prior;
146
224
  try {
225
+ const deliveries = sessionSnapshots.get(ctx.sessionManager.getSessionId())?.deliveries;
147
226
  await sessionDelivery.synchronize(ctx, [
148
- () => claimPendingInteractionDelivery(pi, client, ctx),
149
- () => claimPendingNotificationDelivery(pi, client, ctx),
150
- () => claimPendingTurnDelivery(pi, client, ctx),
227
+ () => claimPendingInteractionDelivery(pi, client, ctx, startOriginActivity),
228
+ ...(deliveries?.notification === true
229
+ ? [() => claimPendingNotificationDelivery(pi, client, ctx)]
230
+ : []),
231
+ ...(deliveries?.turn === true ? [() => claimPendingTurnDelivery(pi, client, ctx)] : []),
151
232
  ]);
152
233
  } finally {
153
234
  sessionView.refresh(ctx);
@@ -155,6 +236,39 @@ export default function piWorkflows(pi: ExtensionAPI): void {
155
236
  }
156
237
  };
157
238
 
239
+ const openPiw = async (
240
+ ctx: ExtensionContext,
241
+ requestedPlacement?: ViewerPlacement,
242
+ ): Promise<void> => {
243
+ const run = sessionSnapshots.get(ctx.sessionManager.getSessionId())?.run;
244
+ if (run === null || run === undefined || !isRecord(run.state)) {
245
+ ctx.ui.notify("No active workflow is available for piw.", "warning");
246
+ return;
247
+ }
248
+ const capability = await herdrViewer.probe();
249
+ if (!capability.available) {
250
+ ctx.ui.notify(capability.reason, "warning");
251
+ return;
252
+ }
253
+ const placement =
254
+ requestedPlacement ??
255
+ ((await ctx.ui.select("Open workflow viewer", [...VIEWER_PLACEMENTS])) as
256
+ | ViewerPlacement
257
+ | undefined);
258
+ if (placement === undefined) return;
259
+ const workflowName =
260
+ typeof run.state.workflowName === "string" ? run.state.workflowName : run.runId;
261
+ const opened = await herdrViewer.open(
262
+ { runId: run.runId, workflowName },
263
+ placement as ViewerPlacement,
264
+ ctx.cwd,
265
+ );
266
+ ctx.ui.notify(
267
+ opened.reused ? "Focused the existing piw view." : "Opened piw in Herdr.",
268
+ "info",
269
+ );
270
+ };
271
+
158
272
  const runToolInOrder = async <T>(operation: () => Promise<T>): Promise<T> => {
159
273
  const prior = toolTail;
160
274
  let release: (() => void) | undefined;
@@ -162,8 +276,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
162
276
  release = resolve;
163
277
  });
164
278
  await prior;
165
- await presentationTail;
166
279
  try {
280
+ await presentationTail;
281
+ const currentContext = sessionContext;
282
+ if (currentContext === null) throw new Error("Workflow session is unavailable");
283
+ await presentInOrder(currentContext);
167
284
  return await operation();
168
285
  } finally {
169
286
  release?.();
@@ -239,7 +356,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
239
356
  }
240
357
  const response = await requestAccepted(client, {
241
358
  operation: params.action === "update" ? "interaction.update" : "interaction.submit",
242
- requestId: `${params.action}-${toolCallId}`,
359
+ requestId: `${params.action}-${toolCallId}-${randomUUID()}`,
243
360
  idempotencyKey: toolCallId,
244
361
  runId: interaction.runId,
245
362
  expectedRevision: interaction.revision,
@@ -251,10 +368,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
251
368
  value:
252
369
  params.action === "update" ? { update: params.update } : { output: params.output },
253
370
  }),
371
+ ...(signal === undefined ? {} : { signal }),
254
372
  });
255
- if (params.action === "submit") {
256
- await waitForInteractionSubmission(interaction.requestId, toolCallId, signal);
257
- }
258
373
  await presentInOrder(ctx);
259
374
  return toolResult(
260
375
  params.action === "update"
@@ -271,6 +386,32 @@ export default function piWorkflows(pi: ExtensionAPI): void {
271
386
  },
272
387
  });
273
388
 
389
+ pi.registerCommand("piw", {
390
+ description: "Open the active workflow in Herdr",
391
+ handler: async (args, ctx) => {
392
+ try {
393
+ const requested = args.trim();
394
+ if (requested.length > 0 && !VIEWER_PLACEMENTS.includes(requested as ViewerPlacement)) {
395
+ throw new Error(`Unknown piw placement: ${requested}`);
396
+ }
397
+ await openPiw(ctx, requested.length === 0 ? undefined : (requested as ViewerPlacement));
398
+ } catch (error) {
399
+ ctx.ui.notify(`Could not open piw: ${errorMessage(error)}`, "warning");
400
+ }
401
+ },
402
+ });
403
+
404
+ pi.registerShortcut(PIW_SHORTCUT, {
405
+ description: "Open the active workflow in Herdr",
406
+ handler: async (ctx) => {
407
+ try {
408
+ await openPiw(ctx);
409
+ } catch (error) {
410
+ ctx.ui.notify(`Could not open piw: ${errorMessage(error)}`, "warning");
411
+ }
412
+ },
413
+ });
414
+
274
415
  pi.registerShortcut("shift+up", {
275
416
  description: "Scroll the workflow widget up",
276
417
  handler: (ctx) => sessionView.scrollUp(ctx),
@@ -281,18 +422,61 @@ export default function piWorkflows(pi: ExtensionAPI): void {
281
422
  handler: (ctx) => sessionView.scrollDown(ctx),
282
423
  });
283
424
 
284
- pi.on("session_start", async (_event, ctx) => {
425
+ pi.on("session_start", (_event, ctx) => {
285
426
  sessionContext = ctx;
286
- try {
287
- await client.ensureRunning();
288
- await presentInOrder(ctx);
289
- } catch (error) {
290
- ctx.ui.notify(`Workflow host is unavailable: ${errorMessage(error)}`, "warning");
291
- }
427
+ const sessionId = ctx.sessionManager.getSessionId();
428
+ const generation = ++sessionGeneration;
429
+ let snapshotGeneration = 0;
430
+ const sessionClient = client;
292
431
  pollTimer = setInterval(() => {
293
432
  if (sessionContext !== null) void presentInOrder(sessionContext).catch(() => undefined);
294
433
  }, INTERACTION_POLL_MS);
295
434
  pollTimer.unref?.();
435
+
436
+ void (async () => {
437
+ try {
438
+ await sessionClient.ensureAvailable();
439
+ const unsubscribe = await sessionClient.watchSession(sessionId, (event) => {
440
+ if (generation !== sessionGeneration || sessionContext !== ctx) return;
441
+ const currentSnapshotGeneration = ++snapshotGeneration;
442
+ if (event.event === "unavailable") {
443
+ sessionSnapshots.delete(sessionId);
444
+ sessionView.clear(ctx);
445
+ return;
446
+ }
447
+ if (!isWorkflowSessionView(event.payload)) return;
448
+ void materializeSessionView(sessionClient, event.payload)
449
+ .then((session) => {
450
+ if (
451
+ generation !== sessionGeneration ||
452
+ currentSnapshotGeneration !== snapshotGeneration ||
453
+ sessionContext !== ctx
454
+ ) {
455
+ return;
456
+ }
457
+ sessionSnapshots.set(sessionId, session);
458
+ sessionView.update(session, ctx);
459
+ void presentInOrder(ctx).catch(() => undefined);
460
+ })
461
+ .catch(() => {
462
+ // A newer session revision retries from its own stable snapshot.
463
+ });
464
+ });
465
+ if (generation !== sessionGeneration || sessionContext !== ctx) {
466
+ await unsubscribe();
467
+ return;
468
+ }
469
+ sessionUnsubscribe = unsubscribe;
470
+ const capability = await herdrViewer.probe();
471
+ if (generation !== sessionGeneration || sessionContext !== ctx) return;
472
+ sessionView.setActionHint(capability.available ? PIW_SHORTCUT_HINT : undefined, ctx);
473
+ await presentInOrder(ctx);
474
+ } catch (error) {
475
+ if (generation === sessionGeneration && sessionContext === ctx) {
476
+ ctx.ui.notify(`Workflow host is unavailable: ${errorMessage(error)}`, "warning");
477
+ }
478
+ }
479
+ })();
296
480
  });
297
481
 
298
482
  pi.on("agent_end", async (event, ctx) => {
@@ -315,6 +499,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
315
499
  (message) => interactionRequestId(message) === interaction.requestId,
316
500
  );
317
501
  if (!turnHasPrompt) return;
502
+ await settleOriginActivity();
318
503
  try {
319
504
  const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
320
505
  await requestAccepted(client, {
@@ -339,15 +524,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
339
524
  } catch (error) {
340
525
  ctx.ui.notify(`Workflow response was rejected: ${errorMessage(error)}`, "error");
341
526
  }
527
+ await settleOriginActivity();
342
528
  await presentInOrder(ctx).catch(() => undefined);
343
529
  });
344
530
 
345
531
  pi.on("session_shutdown", async (_event, ctx) => {
532
+ sessionGeneration += 1;
346
533
  sessionContext = null;
347
534
  sessionDelivery.clear();
348
535
  sessionView.clear(ctx);
536
+ sessionSnapshots.delete(ctx.sessionManager.getSessionId());
537
+ if (activityTimer !== null) clearInterval(activityTimer);
538
+ activityTimer = null;
539
+ originActivity = null;
349
540
  if (pollTimer !== null) clearInterval(pollTimer);
350
541
  pollTimer = null;
542
+ if (sessionUnsubscribe !== null) await sessionUnsubscribe().catch(() => undefined);
543
+ sessionUnsubscribe = null;
544
+ await client.close();
545
+ client = new WorkflowClient({ clientId: `pi-extension-${randomUUID()}` });
351
546
  });
352
547
  }
353
548
 
@@ -358,7 +553,7 @@ type CommandResult = {
358
553
  };
359
554
 
360
555
  async function executeCommand(
361
- client: WorkflowHostClient,
556
+ client: WorkflowClient,
362
557
  ctx: ExtensionContext,
363
558
  command: ParsedWorkflowArgs,
364
559
  idempotencyKey: string = randomUUID(),
@@ -377,7 +572,7 @@ async function executeCommand(
377
572
  };
378
573
  }
379
574
  case "run": {
380
- await client.ensureRunning();
575
+ await client.ensureAvailable();
381
576
  const resolved = await client.resolveWorkflow({ cwd: ctx.cwd, workflowRef: command.ref });
382
577
  const runId = createRunId(resolved.workflowName);
383
578
  const response = await requestAccepted(client, {
@@ -504,7 +699,7 @@ async function executeCommand(
504
699
  }
505
700
 
506
701
  async function executeControllerCommand(
507
- client: WorkflowHostClient,
702
+ client: WorkflowClient,
508
703
  ctx: ExtensionContext,
509
704
  command: ParsedControllerArgs,
510
705
  ): Promise<CommandResult> {
@@ -571,12 +766,18 @@ async function executeControllerCommand(
571
766
 
572
767
  async function claimPendingInteractionDelivery(
573
768
  pi: ExtensionAPI,
574
- client: WorkflowHostClient,
769
+ client: WorkflowClient,
575
770
  ctx: ExtensionContext,
771
+ onPresented: (activity: PresentedOriginActivity) => void,
576
772
  ): Promise<ClaimedSessionDelivery | undefined> {
577
- const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
578
- if (interaction === undefined || interaction.presentationSessionEntryId !== null)
773
+ const storedInteraction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
774
+ if (storedInteraction === undefined || storedInteraction.presentationSessionEntryId !== null)
579
775
  return undefined;
776
+ const interaction = {
777
+ ...storedInteraction,
778
+ contract: await client.hydrateContent(storedInteraction.runId, storedInteraction.contract),
779
+ };
780
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), interaction);
580
781
 
581
782
  const findSessionEntryId = (entries: readonly unknown[]): string | undefined =>
582
783
  entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
@@ -591,7 +792,7 @@ async function claimPendingInteractionDelivery(
591
792
  ) {
592
793
  return undefined;
593
794
  }
594
- await client.ensureRunning();
795
+ await client.ensureAvailable();
595
796
  const claim = await client.request({
596
797
  operation: "interaction.update",
597
798
  requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
@@ -608,6 +809,7 @@ async function claimPendingInteractionDelivery(
608
809
  const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
609
810
  presentationRevision = claim.revision;
610
811
  claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
812
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), claim.receipt);
611
813
  }
612
814
 
613
815
  const contract = interactionContract(interaction);
@@ -616,7 +818,7 @@ async function claimPendingInteractionDelivery(
616
818
  claimExpiresAt,
617
819
  isStillDeliverable: () =>
618
820
  existingEntryId === undefined &&
619
- interactionPresentationClaimIsLive({
821
+ interactionPresentationClaimIsLive(client, {
620
822
  requestId: interaction.requestId,
621
823
  runId: interaction.runId,
622
824
  presenterId: client.clientId,
@@ -664,7 +866,7 @@ async function claimPendingInteractionDelivery(
664
866
  );
665
867
  },
666
868
  settle: async (sessionEntryId) => {
667
- await requestAccepted(client, {
869
+ const response = await requestAccepted(client, {
668
870
  operation: "interaction.update",
669
871
  requestId: `present-${interaction.requestId}-${sessionEntryId}`,
670
872
  idempotencyKey: `present-${interaction.requestId}-${sessionEntryId}`,
@@ -672,17 +874,26 @@ async function claimPendingInteractionDelivery(
672
874
  expectedRevision: presentationRevision,
673
875
  payload: { requestId: interaction.requestId, sessionEntryId },
674
876
  });
877
+ rememberInteractiveRequest(ctx.sessionManager.getSessionId(), response.receipt);
878
+ if (interaction.kind !== "decision") {
879
+ onPresented({
880
+ sessionId: ctx.sessionManager.getSessionId(),
881
+ runId: interaction.runId,
882
+ requestId: interaction.requestId,
883
+ deliveryId: `interaction:${interaction.requestId}`,
884
+ sessionEntryId,
885
+ });
886
+ }
675
887
  },
676
888
  };
677
889
  }
678
890
 
679
891
  async function claimPendingNotificationDelivery(
680
892
  pi: ExtensionAPI,
681
- client: WorkflowHostClient,
893
+ client: WorkflowClient,
682
894
  ctx: ExtensionContext,
683
895
  ): Promise<ClaimedSessionDelivery | undefined> {
684
896
  const sessionId = ctx.sessionManager.getSessionId();
685
- if (!hasClaimableNotification(sessionId)) return undefined;
686
897
  const claimRequestId = randomUUID();
687
898
  const claimed = await requestAccepted(client, {
688
899
  operation: "notification.claim",
@@ -746,13 +957,11 @@ async function claimPendingNotificationDelivery(
746
957
 
747
958
  async function claimPendingTurnDelivery(
748
959
  pi: ExtensionAPI,
749
- client: WorkflowHostClient,
960
+ client: WorkflowClient,
750
961
  ctx: ExtensionContext,
751
962
  ): Promise<ClaimedSessionDelivery | undefined> {
752
963
  const sessionId = ctx.sessionManager.getSessionId();
753
- if (pendingInteractionForSession(sessionId) !== undefined || !hasClaimableTurn(sessionId)) {
754
- return undefined;
755
- }
964
+ if (pendingInteractionForSession(sessionId) !== undefined) return undefined;
756
965
  const claimRequestId = randomUUID();
757
966
  const claimed = await requestAccepted(client, {
758
967
  operation: "turn.claim",
@@ -767,8 +976,10 @@ async function claimPendingTurnDelivery(
767
976
  const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
768
977
  const intentId = requireText(turn.intentId, "turn intentId");
769
978
  const runId = requireText(turn.runId, "turn runId");
770
- const state = terminalRunState(runId);
771
- if (state === undefined) return undefined;
979
+ const claimedRun = await client.getRun(runId);
980
+ if (claimedRun === null) throw new Error(`Claimed workflow turn run is missing: ${runId}`);
981
+ const state = await client.hydrateContent(runId, claimedRun.state);
982
+ if (!isRecord(state)) throw new Error("Workflow terminal state content is invalid");
772
983
  return {
773
984
  deliveryId: `turn:${intentId}`,
774
985
  claimExpiresAt,
@@ -814,7 +1025,7 @@ async function claimPendingTurnDelivery(
814
1025
  }
815
1026
 
816
1027
  async function submitVisibleAssistantResponse(
817
- client: WorkflowHostClient,
1028
+ client: WorkflowClient,
818
1029
  ctx: ExtensionContext,
819
1030
  ): Promise<void> {
820
1031
  const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
@@ -845,171 +1056,78 @@ async function submitVisibleAssistantResponse(
845
1056
  value: submission as unknown as JsonValue,
846
1057
  },
847
1058
  });
848
- await waitForInteractionSubmission(interaction.requestId, `assistant-${responseId}`);
849
- }
850
-
851
- async function waitForInteractionSubmission(
852
- requestId: string,
853
- submissionId: string,
854
- signal?: AbortSignal,
855
- ): Promise<void> {
856
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
857
- try {
858
- for (;;) {
859
- if (signal?.aborted === true) {
860
- throw signal.reason ?? new Error("Workflow submission validation was cancelled");
861
- }
862
- const submission = store.interactionSubmission(requestId, submissionId);
863
- if (submission?.outcome === "accepted" || submission?.outcome === "adopted") return;
864
- if (submission?.outcome === "rejected") {
865
- const receipt = isRecord(submission.receipt) ? submission.receipt : undefined;
866
- throw new Error(
867
- typeof receipt?.error === "string"
868
- ? receipt.error
869
- : "Workflow step output failed validation",
870
- );
871
- }
872
- await waitForSubmissionPoll(signal);
873
- }
874
- } finally {
875
- store.close();
876
- }
877
1059
  }
878
1060
 
879
- async function waitForSubmissionPoll(signal?: AbortSignal): Promise<void> {
880
- if (signal?.aborted === true) {
881
- throw signal.reason ?? new Error("Workflow submission was cancelled");
882
- }
883
- await new Promise<void>((resolve, reject) => {
884
- const timer = setTimeout(finish, SUBMISSION_POLL_MS);
885
- const onAbort = () => finish(signal?.reason ?? new Error("Workflow submission was cancelled"));
886
- function finish(error?: unknown) {
887
- clearTimeout(timer);
888
- signal?.removeEventListener("abort", onAbort);
889
- if (error === undefined) resolve();
890
- else reject(error);
891
- }
892
- signal?.addEventListener("abort", onAbort, { once: true });
1061
+ function rememberInteractiveRequest(sessionId: string, value: unknown): void {
1062
+ const current = parseInteractiveRequest(value);
1063
+ const session = sessionSnapshots.get(sessionId);
1064
+ if (current === undefined || session === undefined) return;
1065
+ let found = false;
1066
+ const pendingInteractions = session.pendingInteractions.map((item) => {
1067
+ const stored = parseInteractiveRequest(item);
1068
+ if (stored?.requestId !== current.requestId) return item;
1069
+ found = true;
1070
+ return current as unknown as JsonValue;
893
1071
  });
1072
+ if (found) sessionSnapshots.set(sessionId, { ...session, pendingInteractions });
894
1073
  }
895
1074
 
896
- function pendingInteractionForSession(sessionId: string): InteractiveRequestRecord | undefined {
897
- try {
898
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
899
- try {
900
- return store.listPendingInteractions(sessionId)[0];
901
- } finally {
902
- store.close();
903
- }
904
- } catch {
905
- return undefined;
906
- }
907
- }
908
-
909
- function pendingDecision(sessionId: string, runId?: string): InteractiveRequestRecord | undefined {
910
- try {
911
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
912
- try {
913
- return store
914
- .listPendingInteractions(sessionId)
915
- .find(
916
- (request) =>
917
- request.kind === "decision" && (runId === undefined || request.runId === runId),
918
- );
919
- } finally {
920
- store.close();
921
- }
922
- } catch {
923
- return undefined;
924
- }
1075
+ function pendingInteractionForSession(sessionId: string): ClientInteractiveRequest | undefined {
1076
+ return sessionSnapshots
1077
+ .get(sessionId)
1078
+ ?.pendingInteractions.map(parseInteractiveRequest)
1079
+ .find((request): request is ClientInteractiveRequest => request !== undefined);
925
1080
  }
926
1081
 
927
- function hasClaimableNotification(sessionId: string): boolean {
928
- try {
929
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
930
- try {
931
- return (
932
- store.listPendingWorkflowNotifications({ targetSessionId: sessionId, limit: 1 }).length > 0
933
- );
934
- } finally {
935
- store.close();
936
- }
937
- } catch {
938
- return false;
939
- }
940
- }
941
-
942
- function hasClaimableTurn(sessionId: string): boolean {
943
- try {
944
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
945
- try {
946
- const now = Date.now();
947
- return store
948
- .listWorkflowTurnIntents({ targetSessionId: sessionId, unresolvedOnly: true, limit: 10 })
949
- .some(
950
- (intent) =>
951
- intent.eligibleAt !== null &&
952
- Date.parse(intent.eligibleAt) <= now &&
953
- (intent.deliveryClaimExpiresAt === null ||
954
- Date.parse(intent.deliveryClaimExpiresAt) <= now),
955
- );
956
- } finally {
957
- store.close();
958
- }
959
- } catch {
960
- return false;
961
- }
962
- }
963
-
964
- function terminalRunState(runId: string): Record<string, unknown> | undefined {
965
- try {
966
- const store = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
967
- try {
968
- const loaded = store.readRun(runId);
969
- return loaded === null ? undefined : (loaded.state as unknown as Record<string, unknown>);
970
- } finally {
971
- store.close();
972
- }
973
- } catch {
974
- return undefined;
975
- }
1082
+ function pendingDecision(sessionId: string, runId?: string): ClientInteractiveRequest | undefined {
1083
+ return sessionSnapshots
1084
+ .get(sessionId)
1085
+ ?.pendingInteractions.map(parseInteractiveRequest)
1086
+ .find(
1087
+ (request): request is ClientInteractiveRequest =>
1088
+ request !== undefined &&
1089
+ request.kind === "decision" &&
1090
+ (runId === undefined || request.runId === runId),
1091
+ );
976
1092
  }
977
1093
 
978
1094
  function workflowRunPaused(runId: string): boolean {
979
- return terminalRunState(runId)?.paused === true;
1095
+ for (const session of sessionSnapshots.values()) {
1096
+ if (session.run?.runId === runId) return session.run.display.status === "paused";
1097
+ }
1098
+ return false;
980
1099
  }
981
1100
 
982
- function interactionPresentationClaimIsLive(options: {
983
- requestId: string;
984
- runId: string;
985
- presenterId: string;
986
- revision: number;
987
- claimExpiresAt: number;
988
- }): boolean {
1101
+ async function interactionPresentationClaimIsLive(
1102
+ client: WorkflowClient,
1103
+ options: {
1104
+ requestId: string;
1105
+ runId: string;
1106
+ presenterId: string;
1107
+ revision: number;
1108
+ claimExpiresAt: number;
1109
+ },
1110
+ ): Promise<boolean> {
1111
+ if (options.claimExpiresAt <= Date.now() || options.presenterId !== client.clientId) return false;
989
1112
  try {
990
- const store = new HostStateStore(workflowStatePath(), { readOnly: true });
991
- try {
992
- const interaction = store.getInteraction(options.requestId);
993
- return (
994
- interaction?.runId === options.runId &&
995
- interaction.status === "presenting" &&
996
- interaction.presenterId === options.presenterId &&
997
- interaction.revision === options.revision &&
998
- interaction.presentationSessionEntryId === null &&
999
- interaction.presentationClaimExpiresAt !== null &&
1000
- Date.parse(interaction.presentationClaimExpiresAt) === options.claimExpiresAt &&
1001
- !workflowRunPaused(options.runId)
1002
- );
1003
- } finally {
1004
- store.close();
1005
- }
1113
+ const response = await client.request({
1114
+ operation: "interaction.update",
1115
+ runId: options.runId,
1116
+ expectedRevision: options.revision,
1117
+ payload: { requestId: options.requestId, validatePresentation: true },
1118
+ });
1119
+ return (
1120
+ (response.outcome === "accepted" || response.outcome === "adopted") &&
1121
+ isRecord(response.receipt) &&
1122
+ response.receipt.live === true
1123
+ );
1006
1124
  } catch {
1007
1125
  return false;
1008
1126
  }
1009
1127
  }
1010
1128
 
1011
1129
  async function hostDeliveryClaimIsLive(
1012
- client: WorkflowHostClient,
1130
+ client: WorkflowClient,
1013
1131
  options: {
1014
1132
  kind: "notification" | "turn";
1015
1133
  resourceId: string;
@@ -1095,25 +1213,18 @@ function claimExpiry(value: unknown, name: string): number {
1095
1213
  return expiry;
1096
1214
  }
1097
1215
 
1098
- function activeSessionRun(ctx: ExtensionContext): WorkflowRunQueueRecord | undefined {
1216
+ function activeSessionRun(ctx: ExtensionContext): WorkflowRunQueueView | undefined {
1099
1217
  return sessionRun(ctx);
1100
1218
  }
1101
1219
 
1102
- function sessionRun(ctx: ExtensionContext, runId?: string): WorkflowRunQueueRecord | undefined {
1103
- try {
1104
- const store = new SqliteControllerStore(workflowStatePath(), { readOnly: true, global: true });
1105
- try {
1106
- const run =
1107
- runId === undefined
1108
- ? store.findSessionReservation(ctx.sessionManager.getSessionId())
1109
- : store.getWorkflowRun(runId);
1110
- return run?.originSessionId === ctx.sessionManager.getSessionId() ? run : undefined;
1111
- } finally {
1112
- store.close();
1113
- }
1114
- } catch {
1220
+ function sessionRun(ctx: ExtensionContext, runId?: string): WorkflowRunQueueView | undefined {
1221
+ const session = sessionSnapshots.get(ctx.sessionManager.getSessionId());
1222
+ if (session?.run === null || session?.run === undefined || !isRecord(session.run.queue)) {
1115
1223
  return undefined;
1116
1224
  }
1225
+ if (runId !== undefined && session.run.runId !== runId) return undefined;
1226
+ const queue = session.run.queue;
1227
+ return queue.originSessionId === ctx.sessionManager.getSessionId() ? queue : undefined;
1117
1228
  }
1118
1229
 
1119
1230
  async function listWorkflowMetadata(cwd: string): Promise<Array<{ name: string; source: string }>> {
@@ -1157,23 +1268,26 @@ function toolInputToCommand(params: ReturnType<typeof parseWorkflowToolInput>):
1157
1268
  }
1158
1269
 
1159
1270
  async function requestAccepted(
1160
- client: WorkflowHostClient,
1161
- options: Parameters<WorkflowHostClient["request"]>[0],
1162
- ): Promise<HostResponse> {
1163
- await client.ensureRunning();
1164
- const response = await client.request(options);
1271
+ client: WorkflowClient,
1272
+ options: Parameters<WorkflowClient["request"]>[0],
1273
+ ): Promise<ClientResponse> {
1274
+ await client.ensureAvailable();
1275
+ const response =
1276
+ options.idempotencyKey === undefined
1277
+ ? await client.request(options)
1278
+ : await client.requestDurable({ ...options, idempotencyKey: options.idempotencyKey });
1165
1279
  if (response.outcome !== "accepted" && response.outcome !== "adopted") {
1166
1280
  throw new Error(response.error ?? `Workflow host rejected ${options.operation}`);
1167
1281
  }
1168
1282
  return response;
1169
1283
  }
1170
1284
 
1171
- function interactionContract(interaction: InteractiveRequestRecord): Record<string, unknown> {
1285
+ function interactionContract(interaction: ClientInteractiveRequest): Record<string, unknown> {
1172
1286
  if (!isRecord(interaction.contract)) throw new Error("Stored interaction contract is invalid");
1173
1287
  return interaction.contract;
1174
1288
  }
1175
1289
 
1176
- function agentContract(interaction: InteractiveRequestRecord): AgentStepContract | undefined {
1290
+ function agentContract(interaction: ClientInteractiveRequest): AgentStepContract | undefined {
1177
1291
  const value = interactionContract(interaction).contract;
1178
1292
  if (
1179
1293
  !isRecord(value) ||
@@ -1296,6 +1410,33 @@ function validRunId(value: string): boolean {
1296
1410
  return /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/u.test(value);
1297
1411
  }
1298
1412
 
1413
+ function isWorkflowSessionView(value: unknown): value is WorkflowSessionView {
1414
+ return (
1415
+ isRecord(value) &&
1416
+ value.schema === "pi-workflows.session-view.v1" &&
1417
+ typeof value.sessionId === "string" &&
1418
+ Array.isArray(value.pendingInteractions) &&
1419
+ isRecord(value.deliveries) &&
1420
+ typeof value.deliveries.notification === "boolean" &&
1421
+ typeof value.deliveries.turn === "boolean" &&
1422
+ (value.run === null || isRecord(value.run))
1423
+ );
1424
+ }
1425
+
1426
+ function parseInteractiveRequest(value: unknown): ClientInteractiveRequest | undefined {
1427
+ if (
1428
+ !isRecord(value) ||
1429
+ typeof value.requestId !== "string" ||
1430
+ typeof value.runId !== "string" ||
1431
+ typeof value.targetSessionId !== "string" ||
1432
+ typeof value.revision !== "number" ||
1433
+ (value.kind !== "agent" && value.kind !== "assistant" && value.kind !== "decision")
1434
+ ) {
1435
+ return undefined;
1436
+ }
1437
+ return value as unknown as ClientInteractiveRequest;
1438
+ }
1439
+
1299
1440
  function isRecord(value: unknown): value is Record<string, unknown> {
1300
1441
  return typeof value === "object" && value !== null && !Array.isArray(value);
1301
1442
  }