@osolmaz/pi-workflows 0.13.1 → 0.13.3

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 (152) hide show
  1. package/README.md +9 -7
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +69 -61
  4. package/dist/builtins/autoimplement.workflow.js +47 -8
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +11 -5
  7. package/dist/builtins/autoplan.workflow.js +68 -32
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +4 -4
  10. package/dist/builtins/change-verification.workflow.d.ts +1 -1
  11. package/dist/builtins/plain-summary.workflow.d.ts +1 -1
  12. package/dist/builtins/plain-summary.workflow.js +35 -24
  13. package/dist/builtins/plain-summary.workflow.js.map +1 -1
  14. package/dist/builtins/plan-approval.workflow.d.ts +1 -1
  15. package/dist/builtins/plan-change.workflow.d.ts +25 -23
  16. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  17. package/dist/builtins/sanity-check.workflow.js +19 -22
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  19. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  20. package/dist/controllers/index.d.ts +2 -2
  21. package/dist/controllers/index.js.map +1 -1
  22. package/dist/controllers/sqlite.js +88 -61
  23. package/dist/controllers/sqlite.js.map +1 -1
  24. package/dist/controllers/types.d.ts +34 -0
  25. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  26. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  27. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  28. package/dist/controllers/workflows.d.ts +8 -1
  29. package/dist/controllers/workflows.js +36 -0
  30. package/dist/controllers/workflows.js.map +1 -1
  31. package/dist/extension/controller-host.d.ts +2 -1
  32. package/dist/extension/controller-host.js +1 -1
  33. package/dist/extension/controller-host.js.map +1 -1
  34. package/dist/extension/decision-channels.js +45 -7
  35. package/dist/extension/decision-channels.js.map +1 -1
  36. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  37. package/dist/extension/follow-up-coordinator.js +131 -0
  38. package/dist/extension/follow-up-coordinator.js.map +1 -0
  39. package/dist/extension/index.d.ts +9 -0
  40. package/dist/extension/index.js +462 -76
  41. package/dist/extension/index.js.map +1 -1
  42. package/dist/extension/recorder.d.ts +1 -1
  43. package/dist/extension/recorder.js +8 -8
  44. package/dist/extension/recorder.js.map +1 -1
  45. package/dist/extension/session-events.d.ts +2 -3
  46. package/dist/extension/session-events.js +11 -10
  47. package/dist/extension/session-events.js.map +1 -1
  48. package/dist/extension/widget.js +9 -0
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/state/database.d.ts +2 -1
  51. package/dist/state/database.js +11 -13
  52. package/dist/state/database.js.map +1 -1
  53. package/dist/state/json.js +6 -1
  54. package/dist/state/json.js.map +1 -1
  55. package/dist/state/mutation.d.ts +1 -1
  56. package/dist/state/mutation.js.map +1 -1
  57. package/dist/state/prune.d.ts +18 -0
  58. package/dist/state/prune.js +373 -0
  59. package/dist/state/prune.js.map +1 -0
  60. package/dist/state/schema.d.ts +1 -1
  61. package/dist/state/schema.js +130 -13
  62. package/dist/state/schema.js.map +1 -1
  63. package/dist/viewer/cli.d.ts +3 -1
  64. package/dist/viewer/cli.js +55 -4
  65. package/dist/viewer/cli.js.map +1 -1
  66. package/dist/viewer/render.js +19 -1
  67. package/dist/viewer/render.js.map +1 -1
  68. package/dist/viewer/session-reducer.d.ts +0 -1
  69. package/dist/viewer/session-reducer.js +2 -24
  70. package/dist/viewer/session-reducer.js.map +1 -1
  71. package/dist/workflows/composition.d.ts +2 -0
  72. package/dist/workflows/composition.js +15 -0
  73. package/dist/workflows/composition.js.map +1 -1
  74. package/dist/workflows/definition.d.ts +6 -3
  75. package/dist/workflows/definition.js +3 -0
  76. package/dist/workflows/definition.js.map +1 -1
  77. package/dist/workflows/engine.d.ts +6 -1
  78. package/dist/workflows/engine.js +303 -36
  79. package/dist/workflows/engine.js.map +1 -1
  80. package/dist/workflows/human-decision.d.ts +1 -0
  81. package/dist/workflows/human-decision.js +25 -9
  82. package/dist/workflows/human-decision.js.map +1 -1
  83. package/dist/workflows/index.d.ts +3 -1
  84. package/dist/workflows/index.js +2 -0
  85. package/dist/workflows/index.js.map +1 -1
  86. package/dist/workflows/json-patch.d.ts +47 -0
  87. package/dist/workflows/json-patch.js +298 -0
  88. package/dist/workflows/json-patch.js.map +1 -0
  89. package/dist/workflows/schema.js +29 -1
  90. package/dist/workflows/schema.js.map +1 -1
  91. package/dist/workflows/settings.d.ts +128 -0
  92. package/dist/workflows/settings.js +199 -0
  93. package/dist/workflows/settings.js.map +1 -0
  94. package/dist/workflows/store.d.ts +81 -3
  95. package/dist/workflows/store.js +1563 -50
  96. package/dist/workflows/store.js.map +1 -1
  97. package/dist/workflows/tool-input.d.ts +22 -0
  98. package/dist/workflows/tool-input.js +43 -0
  99. package/dist/workflows/tool-input.js.map +1 -1
  100. package/dist/workflows/types.d.ts +41 -6
  101. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  102. package/docs/2026-08-25-workflow-settings.md +169 -0
  103. package/docs/DEFERRED_TURNS.md +6 -0
  104. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  105. package/docs/SQLITE_STATE.md +36 -17
  106. package/docs/WORKFLOW_COMPOSITION.md +15 -0
  107. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  108. package/docs/plans/2026-08-25-autoplan-user-intent-capture-plan.md +107 -0
  109. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  110. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  111. package/docs/session-event-journal.md +2 -3
  112. package/docs/tui-viewer.md +7 -6
  113. package/docs/workflows.md +58 -10
  114. package/examples/workflows/live-settings.workflow.ts +93 -0
  115. package/herdr-plugin.toml +1 -1
  116. package/package.json +1 -1
  117. package/src/builtins/autoimplement.workflow.ts +56 -8
  118. package/src/builtins/autoplan.workflow.ts +80 -32
  119. package/src/builtins/catalog.ts +4 -4
  120. package/src/builtins/plain-summary.workflow.ts +38 -40
  121. package/src/builtins/sanity-check.workflow.ts +19 -22
  122. package/src/controllers/index.ts +6 -0
  123. package/src/controllers/sqlite.ts +132 -72
  124. package/src/controllers/types.ts +40 -0
  125. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  126. package/src/controllers/workflows.ts +68 -0
  127. package/src/extension/controller-host.ts +6 -1
  128. package/src/extension/decision-channels.ts +47 -7
  129. package/src/extension/follow-up-coordinator.ts +151 -0
  130. package/src/extension/index.ts +540 -89
  131. package/src/extension/recorder.ts +10 -8
  132. package/src/extension/session-events.ts +15 -13
  133. package/src/extension/widget.ts +8 -0
  134. package/src/state/database.ts +12 -12
  135. package/src/state/json.ts +6 -1
  136. package/src/state/mutation.ts +4 -1
  137. package/src/state/prune.ts +502 -0
  138. package/src/state/schema.ts +130 -13
  139. package/src/viewer/cli.ts +52 -5
  140. package/src/viewer/render.ts +43 -1
  141. package/src/viewer/session-reducer.ts +2 -29
  142. package/src/workflows/composition.ts +19 -0
  143. package/src/workflows/definition.ts +19 -5
  144. package/src/workflows/engine.ts +365 -36
  145. package/src/workflows/human-decision.ts +41 -11
  146. package/src/workflows/index.ts +43 -0
  147. package/src/workflows/json-patch.ts +375 -0
  148. package/src/workflows/schema.ts +31 -1
  149. package/src/workflows/settings.ts +430 -0
  150. package/src/workflows/store.ts +2268 -121
  151. package/src/workflows/tool-input.ts +58 -0
  152. package/src/workflows/types.ts +43 -5
@@ -9,9 +9,10 @@ import type {
9
9
  } from "../controllers/sqlite.js";
10
10
  import type { JsonObject } from "../controllers/types.js";
11
11
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
12
+ import { canonicalJson } from "../state/json.js";
12
13
  import { compositionMetadata } from "../workflows/composition.js";
13
14
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
14
- import { WorkflowEngine } from "../workflows/engine.js";
15
+ import { WorkflowEngine, workflowIdentityMismatch } from "../workflows/engine.js";
15
16
  import {
16
17
  BuiltinWorkflowRevisionChangedError,
17
18
  ClaimLostError,
@@ -77,6 +78,7 @@ import {
77
78
  type DeferredTurnDescriptor,
78
79
  } from "./deferred-turn.js";
79
80
  import { ConversationStepExecutor } from "./executor.js";
81
+ import { FollowUpCoordinator, findSettledPresentationEntries } from "./follow-up-coordinator.js";
80
82
  import {
81
83
  HerdrWorkflowViewer,
82
84
  parseViewerPlacement,
@@ -219,7 +221,7 @@ type StartRunOptions = {
219
221
  };
220
222
 
221
223
  function definitionDigest(snapshot: WorkflowDefinitionSnapshot): string {
222
- return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
224
+ return `sha256:${createHash("sha256").update(canonicalJson(snapshot)).digest("hex")}`;
223
225
  }
224
226
 
225
227
  function launchSourceIdentity(workflow: WorkflowDefinition, root: unknown): unknown {
@@ -291,6 +293,9 @@ export type ParsedWorkflowArgs =
291
293
  | { kind: "resume" }
292
294
  | { kind: "status"; runId?: string }
293
295
  | { kind: "answer"; input: unknown; runId?: string | undefined }
296
+ | { kind: "change-settings"; patch: unknown }
297
+ | { kind: "queue-follow-up"; prompt: string }
298
+ | { kind: "remove-follow-up"; followUpId: string }
294
299
  | { kind: "run"; ref: string; input: unknown };
295
300
 
296
301
  /** Parse `/workflow` arguments. Exported for tests. */
@@ -305,6 +310,28 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
305
310
  if (trimmed === "status") {
306
311
  return { kind: "status" };
307
312
  }
313
+ if (trimmed.startsWith("change-settings ")) {
314
+ const text = trimmed.slice("change-settings".length).trim();
315
+ try {
316
+ return { kind: "change-settings", patch: JSON.parse(text) as unknown };
317
+ } catch (error) {
318
+ throw new Error(
319
+ `change-settings requires a JSON Patch array: ${error instanceof Error ? error.message : String(error)}`,
320
+ );
321
+ }
322
+ }
323
+ if (trimmed.startsWith("queue-follow-up ")) {
324
+ const prompt = trimmed.slice("queue-follow-up".length).trim();
325
+ if (prompt.length === 0) throw new Error("queue-follow-up requires a prompt");
326
+ return { kind: "queue-follow-up", prompt };
327
+ }
328
+ if (trimmed.startsWith("remove-follow-up ")) {
329
+ const followUpId = trimmed.slice("remove-follow-up".length).trim();
330
+ if (!/^follow-up-[a-f0-9]{40}$/u.test(followUpId)) {
331
+ throw new Error("remove-follow-up requires one valid follow-up id");
332
+ }
333
+ return { kind: "remove-follow-up", followUpId };
334
+ }
308
335
  if (trimmed.startsWith("status ")) {
309
336
  const runId = trimmed.slice("status".length).trim();
310
337
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
@@ -364,7 +391,13 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
364
391
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
365
392
  }
366
393
 
367
- function workflowStateSummary(state: WorkflowRunState): JsonObject {
394
+ function workflowStateSummary(
395
+ state: WorkflowRunState,
396
+ controls?: Pick<
397
+ NonNullable<ReturnType<typeof readWorkflowRun>>,
398
+ "settingsScopes" | "followUpQueue"
399
+ >,
400
+ ): JsonObject {
368
401
  const error =
369
402
  state.error === undefined
370
403
  ? undefined
@@ -385,6 +418,33 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
385
418
  ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
386
419
  ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
387
420
  ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
421
+ ...(controls !== undefined
422
+ ? {
423
+ settingsScopes: (controls.settingsScopes ?? []).map((scope) => ({
424
+ scopeId: scope.scopeId,
425
+ mountPath: scope.mountPath,
426
+ invocation: scope.invocation,
427
+ changeNumber: scope.changeNumber,
428
+ settingsHash: scope.settingsHash,
429
+ activeRunId: scope.activeRunId,
430
+ })),
431
+ followUps:
432
+ controls.followUpQueue === null || controls.followUpQueue === undefined
433
+ ? null
434
+ : {
435
+ presentationState: controls.followUpQueue.presentationState,
436
+ items: controls.followUpQueue.followUps.map((followUp) => ({
437
+ followUpId: followUp.followUpId,
438
+ order: followUp.order,
439
+ state: followUp.state,
440
+ source: followUp.source,
441
+ ...(followUp.sessionEntryId !== undefined
442
+ ? { sessionEntryId: followUp.sessionEntryId }
443
+ : {}),
444
+ })),
445
+ },
446
+ }
447
+ : {}),
388
448
  ...(error !== undefined ? { error } : {}),
389
449
  };
390
450
  }
@@ -544,6 +604,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
544
604
  },
545
605
  idle,
546
606
  );
607
+ void followUpCoordinator?.synchronize(ctx, activeRun !== null).catch(() => undefined);
547
608
  } catch {
548
609
  // Delivery retries on the next poll. It never affects workflow execution.
549
610
  }
@@ -575,7 +636,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
575
636
  let sessionClosed = false;
576
637
  let runGeneration = 0;
577
638
  let presentationAbort: AbortController | null = null;
578
- let presentationPending: number | null = null;
639
+ let presentationPending: { generation: number; runId: string } | null = null;
640
+ let followUpStore: WorkflowRunStore | null = null;
641
+ let followUpCoordinator: FollowUpCoordinator | null = null;
579
642
  let controllerHost: PiControllerHost | undefined;
580
643
  let controllerContext: ExtensionContext | null = null;
581
644
 
@@ -603,6 +666,47 @@ export default function piWorkflows(pi: ExtensionAPI) {
603
666
  leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
604
667
  });
605
668
 
669
+ const ensureFollowUpDelivery = (): void => {
670
+ if (followUpStore !== null && followUpCoordinator !== null) return;
671
+ followUpStore?.close();
672
+ followUpStore = new WorkflowRunStore();
673
+ followUpCoordinator = new FollowUpCoordinator(followUpStore, `follow-up-${runnerId}`, (text) =>
674
+ pi.sendUserMessage(text),
675
+ );
676
+ };
677
+
678
+ const settlePresentationFromBranch = (
679
+ ctx: ExtensionContext,
680
+ runId: string,
681
+ unavailableReason?: string,
682
+ ): boolean => {
683
+ if (
684
+ followUpStore === null ||
685
+ followUpStore.readFollowUpQueue(runId)?.presentationState !== "pending"
686
+ ) {
687
+ return false;
688
+ }
689
+ const settled = findSettledPresentationEntries(ctx.sessionManager.getBranch(), runId);
690
+ if (settled !== undefined) {
691
+ followUpStore.settleFollowUpPresentation({
692
+ runId,
693
+ state: "settled",
694
+ presentationEntryId: settled.presentationEntryId,
695
+ assistantEntryId: settled.assistantEntryId,
696
+ });
697
+ return true;
698
+ }
699
+ if (unavailableReason !== undefined) {
700
+ followUpStore.settleFollowUpPresentation({
701
+ runId,
702
+ state: "unavailable",
703
+ reason: unavailableReason,
704
+ });
705
+ return true;
706
+ }
707
+ return false;
708
+ };
709
+
606
710
  // UI updates are best-effort: a captured ctx becomes stale after session
607
711
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
608
712
  // A workflow finishing right as the session goes away must not crash pi.
@@ -986,6 +1090,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
986
1090
  return;
987
1091
  }
988
1092
  if (instructions === undefined || instructions.trim().length === 0) {
1093
+ settlePresentationFromBranch(ctx, run.runId, "No result presentation was available");
989
1094
  if (run.abortProvenance !== undefined) {
990
1095
  makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
991
1096
  }
@@ -997,7 +1102,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
997
1102
  targetSessionId: originSessionId(ctx, run.runId),
998
1103
  resolution: "presentation",
999
1104
  send: (turnIntentId) => {
1000
- presentationPending = run.generation;
1105
+ presentationPending = { generation: run.generation, runId: run.runId };
1001
1106
  pi.sendMessage(
1002
1107
  {
1003
1108
  customType: PRESENTATION_MESSAGE_TYPE,
@@ -1005,6 +1110,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1005
1110
  display: false,
1006
1111
  details: {
1007
1112
  schema: PRESENTATION_MESSAGE_SCHEMA,
1113
+ runId: run.runId,
1008
1114
  ...(turnIntentId === undefined ? {} : { turnIntentId }),
1009
1115
  },
1010
1116
  },
@@ -1018,7 +1124,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1018
1124
  ctx.isIdle() && systemTurnAbort === null,
1019
1125
  );
1020
1126
  } catch (error) {
1021
- if (presentationPending === run.generation) {
1127
+ if (presentationPending?.generation === run.generation) {
1022
1128
  presentationPending = null;
1023
1129
  }
1024
1130
  if (
@@ -1033,6 +1139,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1033
1139
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
1034
1140
  : errorMessage(error);
1035
1141
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
1142
+ settlePresentationFromBranch(ctx, run.runId, message);
1036
1143
  if (run.abortProvenance !== undefined) {
1037
1144
  makeRunTurnIntentEligible(ctx, run, state.status, message);
1038
1145
  }
@@ -1202,6 +1309,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1202
1309
  if (options.signal?.aborted) {
1203
1310
  throw options.signal.reason ?? new Error("Workflow startup aborted");
1204
1311
  }
1312
+ ensureFollowUpDelivery();
1205
1313
  if (activeRun) {
1206
1314
  if (!options.quiet) {
1207
1315
  notify(
@@ -1685,7 +1793,65 @@ export default function piWorkflows(pi: ExtensionAPI) {
1685
1793
  if (controllerHost !== undefined) {
1686
1794
  return controllerHost;
1687
1795
  }
1688
- controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
1796
+ controllerHost = await PiControllerHost.create({
1797
+ cwd: ctx.cwd,
1798
+ startChild,
1799
+ workflowControls: {
1800
+ changeSettings: async (request) => {
1801
+ const result = await changeWorkflowSettings(ctx, {
1802
+ runId: request.runId,
1803
+ ...(request.scopeId !== undefined ? { scopeId: request.scopeId } : {}),
1804
+ ...(request.expectedChangeNumber !== undefined
1805
+ ? { expectedChangeNumber: request.expectedChangeNumber }
1806
+ : {}),
1807
+ patch: request.patch,
1808
+ requestId: request.actorRequestKey,
1809
+ actor: "controller",
1810
+ actorId: request.controllerResourceUid,
1811
+ source: "controller-request",
1812
+ });
1813
+ return {
1814
+ runId: request.runId,
1815
+ scopeId: String(result.details.scopeId),
1816
+ changeNumber: Number(result.details.changeNumber),
1817
+ adopted: result.details.adopted === true,
1818
+ };
1819
+ },
1820
+ queueFollowUp: async (request) => {
1821
+ const result = await queueWorkflowFollowUp(ctx, {
1822
+ runId: request.runId,
1823
+ prompt: request.prompt,
1824
+ requestId: request.actorRequestKey,
1825
+ actor: "controller",
1826
+ actorId: request.controllerResourceUid,
1827
+ source: "controller-request",
1828
+ });
1829
+ return {
1830
+ runId: request.runId,
1831
+ followUpId: String(result.details.followUpId),
1832
+ order: Number(result.details.order),
1833
+ state: "queued",
1834
+ adopted: result.details.adopted === true,
1835
+ };
1836
+ },
1837
+ removeFollowUp: async (request) => {
1838
+ const result = await removeWorkflowFollowUp(ctx, {
1839
+ runId: request.runId,
1840
+ followUpId: request.followUpId,
1841
+ actor: "controller",
1842
+ actorId: request.controllerResourceUid,
1843
+ source: "controller-request",
1844
+ });
1845
+ return {
1846
+ runId: request.runId,
1847
+ followUpId: String(result.details.followUpId),
1848
+ order: Number(result.details.order),
1849
+ state: String(result.details.state),
1850
+ adopted: false,
1851
+ };
1852
+ },
1853
+ },
1854
+ });
1689
1855
  controllerHost?.start();
1690
1856
  updateControllerStatus(ctx);
1691
1857
  return controllerHost;
@@ -1997,7 +2163,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1997
2163
  const { state } = bundle;
1998
2164
  return {
1999
2165
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
2000
- details: workflowStateSummary(state),
2166
+ details: workflowStateSummary(state, bundle),
2001
2167
  };
2002
2168
  }
2003
2169
  const state = activeRun?.lastState ?? widgetSource?.state;
@@ -2016,10 +2182,176 @@ export default function piWorkflows(pi: ExtensionAPI) {
2016
2182
  }
2017
2183
  return {
2018
2184
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
2019
- details: workflowStateSummary(state),
2185
+ details: workflowStateSummary(state, readWorkflowRun(state.runId) ?? undefined),
2020
2186
  };
2021
2187
  };
2022
2188
 
2189
+ const resolveMutableWorkflow = async (
2190
+ ctx: ExtensionContext,
2191
+ requestedRunId?: string,
2192
+ ): Promise<{
2193
+ runId: string;
2194
+ workflow: WorkflowDefinition;
2195
+ store: WorkflowRunStore;
2196
+ }> => {
2197
+ const runId = requestedRunId ?? activeRun?.runId ?? lastWaitingRunId ?? undefined;
2198
+ if (runId === undefined) {
2199
+ throw new Error("No running, parked, or waiting workflow is available for changes.");
2200
+ }
2201
+ const loaded = readWorkflowRun(runId);
2202
+ if (loaded === null) throw new Error(`Workflow run not found: ${runId}`);
2203
+ if (originSessionId(ctx, runId) !== ctx.sessionManager.getSessionId()) {
2204
+ throw new Error("Workflow changes must come from the Pi session that owns the run.");
2205
+ }
2206
+ const source = loaded.state.workflowSource;
2207
+ if (source === undefined) throw new Error(`Workflow run ${runId} has no saved source`);
2208
+ const ref = source.kind === "builtin" ? `builtin:${source.id}` : source.path;
2209
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
2210
+ if (workflowIdentityMismatch(loaded.state, resolved.definition, resolved.source)) {
2211
+ throw new Error(`Workflow source changed since run ${runId} started`);
2212
+ }
2213
+ return { runId, workflow: resolved.definition, store: new WorkflowRunStore() };
2214
+ };
2215
+
2216
+ const changeWorkflowSettings = async (
2217
+ ctx: ExtensionContext,
2218
+ options: {
2219
+ runId?: string;
2220
+ scopeId?: string;
2221
+ expectedChangeNumber?: number;
2222
+ patch: unknown;
2223
+ requestId: string;
2224
+ actor: "session" | "human" | "controller";
2225
+ actorId?: string;
2226
+ source: string;
2227
+ },
2228
+ ): Promise<WorkflowControlResult> => {
2229
+ const target = await resolveMutableWorkflow(ctx, options.runId);
2230
+ try {
2231
+ const scopes = target.store.listSettingsScopes(target.runId);
2232
+ const scopeId =
2233
+ options.scopeId ??
2234
+ (activeRun?.runId === target.runId ? activeRun.engine.activeSettingsScopeId : undefined) ??
2235
+ (scopes.length === 1 ? scopes[0]?.scopeId : undefined);
2236
+ if (scopeId === undefined) {
2237
+ throw new Error("Specify scopeId because this workflow has more than one settings scope.");
2238
+ }
2239
+ const scope = target.store.getSettingsScope(scopeId);
2240
+ if (scope === undefined || scope.activeRunId !== target.runId) {
2241
+ throw new Error(`Workflow settings scope not found for run ${target.runId}: ${scopeId}`);
2242
+ }
2243
+ const definition =
2244
+ scope.mountPath === ""
2245
+ ? target.workflow.settings
2246
+ : compositionMetadata(target.workflow)?.scopes[scope.mountPath]?.settings;
2247
+ if (definition === undefined) {
2248
+ throw new Error(`Workflow scope ${scopeId} does not declare editable settings.`);
2249
+ }
2250
+ const result = await target.store.changeSettings(definition, {
2251
+ runId: target.runId,
2252
+ scopeId,
2253
+ requestId: options.requestId,
2254
+ ...(options.expectedChangeNumber !== undefined
2255
+ ? { expectedChangeNumber: options.expectedChangeNumber }
2256
+ : {}),
2257
+ actor: {
2258
+ type: options.actor,
2259
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
2260
+ },
2261
+ source: options.source,
2262
+ patch: options.patch,
2263
+ });
2264
+ renderWidget(ctx);
2265
+ return {
2266
+ message: `Workflow settings changed to number ${result.scope.changeNumber}.`,
2267
+ details: {
2268
+ action: "change-settings",
2269
+ runId: target.runId,
2270
+ scopeId,
2271
+ changeNumber: result.scope.changeNumber,
2272
+ adopted: result.adopted,
2273
+ },
2274
+ };
2275
+ } finally {
2276
+ target.store.close();
2277
+ }
2278
+ };
2279
+
2280
+ const queueWorkflowFollowUp = async (
2281
+ ctx: ExtensionContext,
2282
+ options: {
2283
+ runId?: string;
2284
+ prompt: string;
2285
+ requestId: string;
2286
+ actor: "session" | "human" | "controller";
2287
+ actorId?: string;
2288
+ source: string;
2289
+ },
2290
+ ): Promise<WorkflowControlResult> => {
2291
+ const target = await resolveMutableWorkflow(ctx, options.runId);
2292
+ try {
2293
+ const result = target.store.queueFollowUp({
2294
+ runId: target.runId,
2295
+ requestId: options.requestId,
2296
+ targetSessionId: ctx.sessionManager.getSessionId(),
2297
+ actor: {
2298
+ type: options.actor,
2299
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
2300
+ },
2301
+ source: options.source,
2302
+ prompt: options.prompt,
2303
+ });
2304
+ return {
2305
+ message: `Queued workflow follow-up ${result.followUp.order}.`,
2306
+ details: {
2307
+ action: "queue-follow-up",
2308
+ runId: target.runId,
2309
+ followUpId: result.followUp.followUpId,
2310
+ order: result.followUp.order,
2311
+ adopted: result.adopted,
2312
+ },
2313
+ };
2314
+ } finally {
2315
+ target.store.close();
2316
+ }
2317
+ };
2318
+
2319
+ const removeWorkflowFollowUp = async (
2320
+ ctx: ExtensionContext,
2321
+ options: {
2322
+ runId?: string;
2323
+ followUpId: string;
2324
+ actor: "session" | "human" | "controller";
2325
+ actorId?: string;
2326
+ source: string;
2327
+ },
2328
+ ): Promise<WorkflowControlResult> => {
2329
+ const target = await resolveMutableWorkflow(ctx, options.runId);
2330
+ try {
2331
+ const followUp = target.store.removeFollowUp({
2332
+ runId: target.runId,
2333
+ followUpId: options.followUpId,
2334
+ actor: {
2335
+ type: options.actor,
2336
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
2337
+ },
2338
+ source: options.source,
2339
+ });
2340
+ return {
2341
+ message: `Removed workflow follow-up ${followUp.order}.`,
2342
+ details: {
2343
+ action: "remove-follow-up",
2344
+ runId: target.runId,
2345
+ followUpId: followUp.followUpId,
2346
+ order: followUp.order,
2347
+ state: followUp.state,
2348
+ },
2349
+ };
2350
+ } finally {
2351
+ target.store.close();
2352
+ }
2353
+ };
2354
+
2023
2355
  const resolveWaitingWorkflow = async (
2024
2356
  ctx: ExtensionContext,
2025
2357
  requestedRunId?: string,
@@ -2622,7 +2954,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2622
2954
 
2623
2955
  pi.registerCommand("workflow", {
2624
2956
  description:
2625
- "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
2957
+ "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer, change-settings, queue-follow-up, remove-follow-up",
2626
2958
  getArgumentCompletions: async (prefix: string) => {
2627
2959
  const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
2628
2960
  const items = [
@@ -2632,6 +2964,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
2632
2964
  { value: "resume", label: "resume" },
2633
2965
  { value: "cancel", label: "cancel" },
2634
2966
  { value: "answer", label: "answer" },
2967
+ { value: "change-settings", label: "change-settings" },
2968
+ { value: "queue-follow-up", label: "queue-follow-up" },
2969
+ { value: "remove-follow-up", label: "remove-follow-up" },
2635
2970
  ].filter((item) => item.value.startsWith(prefix));
2636
2971
  return items.length > 0 ? items : null;
2637
2972
  },
@@ -2671,6 +3006,47 @@ export default function piWorkflows(pi: ExtensionAPI) {
2671
3006
  }
2672
3007
  return;
2673
3008
  }
3009
+ if (parsed.kind === "change-settings") {
3010
+ try {
3011
+ const result = await changeWorkflowSettings(ctx, {
3012
+ patch: parsed.patch,
3013
+ requestId: `interactive-${randomUUID()}`,
3014
+ actor: "human",
3015
+ source: "interactive-command",
3016
+ });
3017
+ notify(ctx, result.message, result.level);
3018
+ } catch (error) {
3019
+ notify(ctx, errorMessage(error), "error");
3020
+ }
3021
+ return;
3022
+ }
3023
+ if (parsed.kind === "queue-follow-up") {
3024
+ try {
3025
+ const result = await queueWorkflowFollowUp(ctx, {
3026
+ prompt: parsed.prompt,
3027
+ requestId: `interactive-${randomUUID()}`,
3028
+ actor: "human",
3029
+ source: "interactive-command",
3030
+ });
3031
+ notify(ctx, result.message, result.level);
3032
+ } catch (error) {
3033
+ notify(ctx, errorMessage(error), "error");
3034
+ }
3035
+ return;
3036
+ }
3037
+ if (parsed.kind === "remove-follow-up") {
3038
+ try {
3039
+ const result = await removeWorkflowFollowUp(ctx, {
3040
+ followUpId: parsed.followUpId,
3041
+ actor: "human",
3042
+ source: "interactive-command",
3043
+ });
3044
+ notify(ctx, result.message, result.level);
3045
+ } catch (error) {
3046
+ notify(ctx, errorMessage(error), "error");
3047
+ }
3048
+ return;
3049
+ }
2674
3050
  if (parsed.kind === "answer") {
2675
3051
  try {
2676
3052
  const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
@@ -2829,11 +3205,26 @@ export default function piWorkflows(pi: ExtensionAPI) {
2829
3205
  },
2830
3206
  });
2831
3207
 
3208
+ let workflowToolTail = Promise.resolve();
3209
+ const withWorkflowToolOrder = async <T>(operation: () => Promise<T>): Promise<T> => {
3210
+ const prior = workflowToolTail;
3211
+ let release: (() => void) | undefined;
3212
+ workflowToolTail = new Promise<void>((resolve) => {
3213
+ release = resolve;
3214
+ });
3215
+ await prior;
3216
+ try {
3217
+ return await operation();
3218
+ } finally {
3219
+ release?.();
3220
+ }
3221
+ };
3222
+
2832
3223
  pi.registerTool({
2833
3224
  name: "workflow",
2834
3225
  label: "Workflow",
2835
3226
  description: [
2836
- "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
3227
+ "List, start, inspect, change settings, queue or remove follow-ups, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2837
3228
  "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, stopWhen, everyMinutes, and optional maxChecks.",
2838
3229
  "Put the exact goal, authority, limits, and recovery rules in task; Monitor observes first, performs only safe authorized actions, verifies them immediately, and waits only while target work is moving or an external event is pending.",
2839
3230
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
@@ -2841,89 +3232,121 @@ export default function piWorkflows(pi: ExtensionAPI) {
2841
3232
  ].join(" "),
2842
3233
  parameters: WorkflowToolParameters,
2843
3234
  async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
2844
- const params = parseWorkflowToolInput(rawParams);
2845
- let control: WorkflowControlResult;
2846
- switch (params.action) {
2847
- case "list":
2848
- control = await listWorkflowControl(ctx, params.offset);
2849
- break;
2850
- case "start":
2851
- control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
2852
- break;
2853
- case "status":
2854
- control = await statusWorkflowControl(ctx, params.runId);
2855
- break;
2856
- case "pause":
2857
- control = pauseWorkflowControl(ctx);
2858
- break;
2859
- case "resume":
2860
- control = resumeWorkflowControl(ctx);
2861
- break;
2862
- case "cancel":
2863
- control = await cancelWorkflowControl(ctx, "agent", params.runId);
2864
- break;
2865
- case "answer": {
2866
- const waiting = await resolveWaitingWorkflow(ctx, params.runId);
2867
- const parent = readWorkflowRun(waiting.parentRunId);
2868
- if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2869
- throw new Error(
2870
- "This checkpoint requires a verified human answer from Pi UI or a configured decision channel.",
2871
- );
2872
- }
2873
- control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
2874
- parentRunId: waiting.parentRunId,
2875
- });
2876
- break;
2877
- }
2878
- case "update": {
2879
- if (!activeRun) throw new Error("No workflow step is active.");
2880
- const receipt = await activeRun.engine.publishUpdate(
2881
- params.step,
2882
- params.attempt,
2883
- params.update,
2884
- toolCallId,
2885
- );
2886
- return {
2887
- content: [
2888
- { type: "text", text: "Workflow update published; the step remains active." },
2889
- ],
2890
- details: { action: "update", ...receipt },
2891
- };
2892
- }
2893
- case "submit": {
2894
- if (!activeRun) {
2895
- if (
2896
- lastExpiredAttempt?.contract.attemptId === params.attempt &&
2897
- lastExpiredAttempt.contract.nodeId === params.step
2898
- ) {
3235
+ return await withWorkflowToolOrder(async () => {
3236
+ const params = parseWorkflowToolInput(rawParams);
3237
+ let control: WorkflowControlResult;
3238
+ switch (params.action) {
3239
+ case "list":
3240
+ control = await listWorkflowControl(ctx, params.offset);
3241
+ break;
3242
+ case "start":
3243
+ control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
3244
+ break;
3245
+ case "status":
3246
+ control = await statusWorkflowControl(ctx, params.runId);
3247
+ break;
3248
+ case "pause":
3249
+ control = pauseWorkflowControl(ctx);
3250
+ break;
3251
+ case "resume":
3252
+ control = resumeWorkflowControl(ctx);
3253
+ break;
3254
+ case "cancel":
3255
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
3256
+ break;
3257
+ case "answer": {
3258
+ const waiting = await resolveWaitingWorkflow(ctx, params.runId);
3259
+ const parent = readWorkflowRun(waiting.parentRunId);
3260
+ if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2899
3261
  throw new Error(
2900
- `Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
3262
+ "This checkpoint requires a verified human answer from Pi UI or a configured decision channel.",
2901
3263
  );
2902
3264
  }
2903
- throw new Error("No workflow step is waiting for output.");
3265
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
3266
+ parentRunId: waiting.parentRunId,
3267
+ });
3268
+ break;
2904
3269
  }
2905
- // Flush the conversation into SQLite before accepting, so the
2906
- // attempt range includes the assistant message carrying this call.
2907
- await activeRun.recorder?.record(ctx).catch(() => undefined);
2908
- await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
2909
- const result = await activeRun.executor.submit(
2910
- params.step,
2911
- params.attempt,
2912
- params.output,
2913
- );
2914
- if (!result.accepted) {
2915
- throw new Error(result.message);
3270
+ case "change-settings":
3271
+ control = await changeWorkflowSettings(ctx, {
3272
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
3273
+ ...(params.scopeId !== undefined ? { scopeId: params.scopeId } : {}),
3274
+ ...(params.expectedChangeNumber !== undefined
3275
+ ? { expectedChangeNumber: params.expectedChangeNumber }
3276
+ : {}),
3277
+ patch: params.patch,
3278
+ requestId: `tool-${toolCallId}`,
3279
+ actor: "session",
3280
+ source: "workflow-tool",
3281
+ });
3282
+ break;
3283
+ case "queue-follow-up":
3284
+ control = await queueWorkflowFollowUp(ctx, {
3285
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
3286
+ prompt: params.prompt,
3287
+ requestId: `tool-${toolCallId}`,
3288
+ actor: "session",
3289
+ source: "workflow-tool",
3290
+ });
3291
+ break;
3292
+ case "remove-follow-up":
3293
+ control = await removeWorkflowFollowUp(ctx, {
3294
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
3295
+ followUpId: params.followUpId,
3296
+ actor: "session",
3297
+ source: "workflow-tool",
3298
+ });
3299
+ break;
3300
+ case "update": {
3301
+ if (!activeRun) throw new Error("No workflow step is active.");
3302
+ const receipt = await activeRun.engine.publishUpdate(
3303
+ params.step,
3304
+ params.attempt,
3305
+ params.update,
3306
+ toolCallId,
3307
+ );
3308
+ return {
3309
+ content: [
3310
+ { type: "text", text: "Workflow update published; the step remains active." },
3311
+ ],
3312
+ details: { action: "update", ...receipt },
3313
+ };
3314
+ }
3315
+ case "submit": {
3316
+ if (!activeRun) {
3317
+ if (
3318
+ lastExpiredAttempt?.contract.attemptId === params.attempt &&
3319
+ lastExpiredAttempt.contract.nodeId === params.step
3320
+ ) {
3321
+ throw new Error(
3322
+ `Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`,
3323
+ );
3324
+ }
3325
+ throw new Error("No workflow step is waiting for output.");
3326
+ }
3327
+ // Flush the conversation into SQLite before accepting, so the
3328
+ // attempt range includes the assistant message carrying this call.
3329
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
3330
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
3331
+ const result = await activeRun.executor.submit(
3332
+ params.step,
3333
+ params.attempt,
3334
+ params.output,
3335
+ );
3336
+ if (!result.accepted) {
3337
+ throw new Error(result.message);
3338
+ }
3339
+ return {
3340
+ content: [{ type: "text", text: result.message }],
3341
+ details: { action: "submit", step: params.step, accepted: true },
3342
+ };
2916
3343
  }
2917
- return {
2918
- content: [{ type: "text", text: result.message }],
2919
- details: { action: "submit", step: params.step, accepted: true },
2920
- };
2921
3344
  }
2922
- }
2923
- return {
2924
- content: [{ type: "text", text: control.message }],
2925
- details: control.details,
2926
- };
3345
+ return {
3346
+ content: [{ type: "text", text: control.message }],
3347
+ details: control.details,
3348
+ };
3349
+ });
2927
3350
  },
2928
3351
  });
2929
3352
 
@@ -2949,6 +3372,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
2949
3372
  controllerContext = ctx;
2950
3373
  if (herdrEnabled) void refreshHerdrCapability(ctx);
2951
3374
  ensureRunQueueStore(ctx.cwd);
3375
+ ensureFollowUpDelivery();
3376
+ const sessionFollowUpStore = followUpStore;
3377
+ const sessionFollowUpCoordinator = followUpCoordinator;
3378
+ if (sessionFollowUpStore === null || sessionFollowUpCoordinator === null) {
3379
+ throw new Error("Workflow follow-up delivery did not initialize");
3380
+ }
3381
+ for (const runId of sessionFollowUpStore.listPendingPresentations(
3382
+ ctx.sessionManager.getSessionId(),
3383
+ )) {
3384
+ settlePresentationFromBranch(
3385
+ ctx,
3386
+ runId,
3387
+ "The final workflow response did not settle before the extension restarted",
3388
+ );
3389
+ }
3390
+ await sessionFollowUpCoordinator.synchronize(ctx, false).catch(() => undefined);
2952
3391
  try {
2953
3392
  await reloadDecisionChannels(ctx);
2954
3393
  } catch {
@@ -3069,6 +3508,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
3069
3508
 
3070
3509
  pi.on("agent_settled", async (_event, ctx) => {
3071
3510
  systemTurnAbort = null;
3511
+ if (presentationPending !== null) {
3512
+ settlePresentationFromBranch(
3513
+ ctx,
3514
+ presentationPending.runId,
3515
+ "The final workflow response settled without durable session evidence",
3516
+ );
3517
+ presentationPending = null;
3518
+ }
3072
3519
  if (activeRun === null) {
3073
3520
  try {
3074
3521
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
@@ -3090,7 +3537,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
3090
3537
  const run = activeRun;
3091
3538
  if (!run) {
3092
3539
  turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
3093
- presentationPending = null;
3540
+ await followUpCoordinator?.synchronize(ctx, false).catch(() => undefined);
3094
3541
  runSyncPass(ctx);
3095
3542
  return;
3096
3543
  }
@@ -3145,6 +3592,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
3145
3592
  controllerContext = null;
3146
3593
  runQueueStore?.close();
3147
3594
  runQueueStore = null;
3595
+ followUpCoordinator?.clear();
3596
+ followUpCoordinator = null;
3597
+ followUpStore?.close();
3598
+ followUpStore = null;
3148
3599
  presentationPending = null;
3149
3600
  clearWidgetTimer();
3150
3601
  stopWidgetTicker();