@osolmaz/pi-workflows 0.13.2 → 0.13.4

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 (134) hide show
  1. package/README.md +5 -3
  2. package/dist/builtins/autodoc.workflow.d.ts +5 -5
  3. package/dist/builtins/autoimplement.workflow.d.ts +77 -73
  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 +8 -8
  7. package/dist/builtins/autoplan.workflow.js +44 -35
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +2 -2
  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/plan-approval.workflow.d.ts +1 -1
  13. package/dist/builtins/plan-change.workflow.d.ts +29 -29
  14. package/dist/builtins/sanity-check.workflow.d.ts +3 -3
  15. package/dist/builtins/workspace-preparation.workflow.d.ts +1 -1
  16. package/dist/controllers/index.d.ts +2 -2
  17. package/dist/controllers/index.js.map +1 -1
  18. package/dist/controllers/sqlite.js +85 -23
  19. package/dist/controllers/sqlite.js.map +1 -1
  20. package/dist/controllers/types.d.ts +34 -0
  21. package/dist/controllers/workflow-engine-scheduler.d.ts +5 -1
  22. package/dist/controllers/workflow-engine-scheduler.js +83 -1
  23. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  24. package/dist/controllers/workflows.d.ts +8 -1
  25. package/dist/controllers/workflows.js +36 -0
  26. package/dist/controllers/workflows.js.map +1 -1
  27. package/dist/extension/controller-host.d.ts +2 -1
  28. package/dist/extension/controller-host.js +1 -1
  29. package/dist/extension/controller-host.js.map +1 -1
  30. package/dist/extension/follow-up-coordinator.d.ts +27 -0
  31. package/dist/extension/follow-up-coordinator.js +131 -0
  32. package/dist/extension/follow-up-coordinator.js.map +1 -0
  33. package/dist/extension/index.d.ts +9 -0
  34. package/dist/extension/index.js +460 -75
  35. package/dist/extension/index.js.map +1 -1
  36. package/dist/extension/session-events.d.ts +2 -3
  37. package/dist/extension/session-events.js +11 -10
  38. package/dist/extension/session-events.js.map +1 -1
  39. package/dist/extension/widget.js +9 -0
  40. package/dist/extension/widget.js.map +1 -1
  41. package/dist/state/database.d.ts +2 -1
  42. package/dist/state/database.js +11 -13
  43. package/dist/state/database.js.map +1 -1
  44. package/dist/state/json.js +6 -1
  45. package/dist/state/json.js.map +1 -1
  46. package/dist/state/mutation.d.ts +1 -1
  47. package/dist/state/mutation.js.map +1 -1
  48. package/dist/state/prune.d.ts +18 -0
  49. package/dist/state/prune.js +373 -0
  50. package/dist/state/prune.js.map +1 -0
  51. package/dist/state/schema.d.ts +1 -1
  52. package/dist/state/schema.js +116 -18
  53. package/dist/state/schema.js.map +1 -1
  54. package/dist/viewer/cli.d.ts +3 -1
  55. package/dist/viewer/cli.js +55 -4
  56. package/dist/viewer/cli.js.map +1 -1
  57. package/dist/viewer/render.js +19 -1
  58. package/dist/viewer/render.js.map +1 -1
  59. package/dist/workflows/composition.d.ts +2 -0
  60. package/dist/workflows/composition.js +15 -0
  61. package/dist/workflows/composition.js.map +1 -1
  62. package/dist/workflows/definition.d.ts +6 -3
  63. package/dist/workflows/definition.js +3 -0
  64. package/dist/workflows/definition.js.map +1 -1
  65. package/dist/workflows/engine.d.ts +6 -1
  66. package/dist/workflows/engine.js +301 -35
  67. package/dist/workflows/engine.js.map +1 -1
  68. package/dist/workflows/human-decision.d.ts +1 -0
  69. package/dist/workflows/human-decision.js +25 -9
  70. package/dist/workflows/human-decision.js.map +1 -1
  71. package/dist/workflows/index.d.ts +3 -1
  72. package/dist/workflows/index.js +2 -0
  73. package/dist/workflows/index.js.map +1 -1
  74. package/dist/workflows/json-patch.d.ts +47 -0
  75. package/dist/workflows/json-patch.js +298 -0
  76. package/dist/workflows/json-patch.js.map +1 -0
  77. package/dist/workflows/schema.js +29 -1
  78. package/dist/workflows/schema.js.map +1 -1
  79. package/dist/workflows/settings.d.ts +128 -0
  80. package/dist/workflows/settings.js +199 -0
  81. package/dist/workflows/settings.js.map +1 -0
  82. package/dist/workflows/store.d.ts +75 -2
  83. package/dist/workflows/store.js +1349 -89
  84. package/dist/workflows/store.js.map +1 -1
  85. package/dist/workflows/tool-input.d.ts +22 -0
  86. package/dist/workflows/tool-input.js +43 -0
  87. package/dist/workflows/tool-input.js.map +1 -1
  88. package/dist/workflows/types.d.ts +40 -5
  89. package/docs/2026-08-25-workflow-follow-ups.md +132 -0
  90. package/docs/2026-08-25-workflow-settings.md +169 -0
  91. package/docs/DEFERRED_TURNS.md +6 -0
  92. package/docs/DESIGN_PHILOSOPHY.md +2 -0
  93. package/docs/SQLITE_STATE.md +31 -18
  94. package/docs/WORKFLOW_COMPOSITION.md +16 -1
  95. package/docs/WORKFLOW_STEP_MESSAGES.md +4 -2
  96. package/docs/plans/2026-08-25-live-workflow-settings-plan.md +532 -0
  97. package/docs/plans/2026-08-25-workflow-run-storage-plan.md +67 -0
  98. package/docs/tui-viewer.md +7 -6
  99. package/docs/workflows.md +22 -1
  100. package/examples/workflows/live-settings.workflow.ts +93 -0
  101. package/herdr-plugin.toml +1 -1
  102. package/package.json +1 -1
  103. package/skills/autoplan/SKILL.md +6 -6
  104. package/src/builtins/autoimplement.workflow.ts +56 -8
  105. package/src/builtins/autoplan.workflow.ts +45 -37
  106. package/src/builtins/catalog.ts +2 -2
  107. package/src/controllers/index.ts +6 -0
  108. package/src/controllers/sqlite.ts +129 -35
  109. package/src/controllers/types.ts +40 -0
  110. package/src/controllers/workflow-engine-scheduler.ts +103 -1
  111. package/src/controllers/workflows.ts +68 -0
  112. package/src/extension/controller-host.ts +6 -1
  113. package/src/extension/follow-up-coordinator.ts +151 -0
  114. package/src/extension/index.ts +538 -88
  115. package/src/extension/session-events.ts +15 -13
  116. package/src/extension/widget.ts +8 -0
  117. package/src/state/database.ts +12 -12
  118. package/src/state/json.ts +6 -1
  119. package/src/state/mutation.ts +4 -1
  120. package/src/state/prune.ts +502 -0
  121. package/src/state/schema.ts +116 -18
  122. package/src/viewer/cli.ts +52 -5
  123. package/src/viewer/render.ts +43 -1
  124. package/src/workflows/composition.ts +19 -0
  125. package/src/workflows/definition.ts +19 -5
  126. package/src/workflows/engine.ts +363 -35
  127. package/src/workflows/human-decision.ts +41 -11
  128. package/src/workflows/index.ts +43 -0
  129. package/src/workflows/json-patch.ts +375 -0
  130. package/src/workflows/schema.ts +31 -1
  131. package/src/workflows/settings.ts +430 -0
  132. package/src/workflows/store.ts +1977 -184
  133. package/src/workflows/tool-input.ts +58 -0
  134. package/src/workflows/types.ts +43 -4
@@ -12,7 +12,7 @@ import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
12
12
  import { canonicalJson } from "../state/json.js";
13
13
  import { compositionMetadata } from "../workflows/composition.js";
14
14
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
15
- import { WorkflowEngine } from "../workflows/engine.js";
15
+ import { WorkflowEngine, workflowIdentityMismatch } from "../workflows/engine.js";
16
16
  import {
17
17
  BuiltinWorkflowRevisionChangedError,
18
18
  ClaimLostError,
@@ -78,6 +78,7 @@ import {
78
78
  type DeferredTurnDescriptor,
79
79
  } from "./deferred-turn.js";
80
80
  import { ConversationStepExecutor } from "./executor.js";
81
+ import { FollowUpCoordinator, findSettledPresentationEntries } from "./follow-up-coordinator.js";
81
82
  import {
82
83
  HerdrWorkflowViewer,
83
84
  parseViewerPlacement,
@@ -292,6 +293,9 @@ export type ParsedWorkflowArgs =
292
293
  | { kind: "resume" }
293
294
  | { kind: "status"; runId?: string }
294
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 }
295
299
  | { kind: "run"; ref: string; input: unknown };
296
300
 
297
301
  /** Parse `/workflow` arguments. Exported for tests. */
@@ -306,6 +310,28 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
306
310
  if (trimmed === "status") {
307
311
  return { kind: "status" };
308
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
+ }
309
335
  if (trimmed.startsWith("status ")) {
310
336
  const runId = trimmed.slice("status".length).trim();
311
337
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
@@ -365,7 +391,13 @@ export function parseWorkflowArgs(args: string): ParsedWorkflowArgs {
365
391
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
366
392
  }
367
393
 
368
- 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 {
369
401
  const error =
370
402
  state.error === undefined
371
403
  ? undefined
@@ -386,6 +418,33 @@ function workflowStateSummary(state: WorkflowRunState): JsonObject {
386
418
  ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
387
419
  ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
388
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
+ : {}),
389
448
  ...(error !== undefined ? { error } : {}),
390
449
  };
391
450
  }
@@ -545,6 +604,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
545
604
  },
546
605
  idle,
547
606
  );
607
+ void followUpCoordinator?.synchronize(ctx, activeRun !== null).catch(() => undefined);
548
608
  } catch {
549
609
  // Delivery retries on the next poll. It never affects workflow execution.
550
610
  }
@@ -576,7 +636,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
576
636
  let sessionClosed = false;
577
637
  let runGeneration = 0;
578
638
  let presentationAbort: AbortController | null = null;
579
- 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;
580
642
  let controllerHost: PiControllerHost | undefined;
581
643
  let controllerContext: ExtensionContext | null = null;
582
644
 
@@ -604,6 +666,47 @@ export default function piWorkflows(pi: ExtensionAPI) {
604
666
  leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
605
667
  });
606
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
+
607
710
  // UI updates are best-effort: a captured ctx becomes stale after session
608
711
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
609
712
  // A workflow finishing right as the session goes away must not crash pi.
@@ -987,6 +1090,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
987
1090
  return;
988
1091
  }
989
1092
  if (instructions === undefined || instructions.trim().length === 0) {
1093
+ settlePresentationFromBranch(ctx, run.runId, "No result presentation was available");
990
1094
  if (run.abortProvenance !== undefined) {
991
1095
  makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
992
1096
  }
@@ -998,7 +1102,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
998
1102
  targetSessionId: originSessionId(ctx, run.runId),
999
1103
  resolution: "presentation",
1000
1104
  send: (turnIntentId) => {
1001
- presentationPending = run.generation;
1105
+ presentationPending = { generation: run.generation, runId: run.runId };
1002
1106
  pi.sendMessage(
1003
1107
  {
1004
1108
  customType: PRESENTATION_MESSAGE_TYPE,
@@ -1006,6 +1110,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1006
1110
  display: false,
1007
1111
  details: {
1008
1112
  schema: PRESENTATION_MESSAGE_SCHEMA,
1113
+ runId: run.runId,
1009
1114
  ...(turnIntentId === undefined ? {} : { turnIntentId }),
1010
1115
  },
1011
1116
  },
@@ -1019,7 +1124,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1019
1124
  ctx.isIdle() && systemTurnAbort === null,
1020
1125
  );
1021
1126
  } catch (error) {
1022
- if (presentationPending === run.generation) {
1127
+ if (presentationPending?.generation === run.generation) {
1023
1128
  presentationPending = null;
1024
1129
  }
1025
1130
  if (
@@ -1034,6 +1139,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1034
1139
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
1035
1140
  : errorMessage(error);
1036
1141
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
1142
+ settlePresentationFromBranch(ctx, run.runId, message);
1037
1143
  if (run.abortProvenance !== undefined) {
1038
1144
  makeRunTurnIntentEligible(ctx, run, state.status, message);
1039
1145
  }
@@ -1203,6 +1309,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1203
1309
  if (options.signal?.aborted) {
1204
1310
  throw options.signal.reason ?? new Error("Workflow startup aborted");
1205
1311
  }
1312
+ ensureFollowUpDelivery();
1206
1313
  if (activeRun) {
1207
1314
  if (!options.quiet) {
1208
1315
  notify(
@@ -1686,7 +1793,65 @@ export default function piWorkflows(pi: ExtensionAPI) {
1686
1793
  if (controllerHost !== undefined) {
1687
1794
  return controllerHost;
1688
1795
  }
1689
- 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
+ });
1690
1855
  controllerHost?.start();
1691
1856
  updateControllerStatus(ctx);
1692
1857
  return controllerHost;
@@ -1998,7 +2163,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1998
2163
  const { state } = bundle;
1999
2164
  return {
2000
2165
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
2001
- details: workflowStateSummary(state),
2166
+ details: workflowStateSummary(state, bundle),
2002
2167
  };
2003
2168
  }
2004
2169
  const state = activeRun?.lastState ?? widgetSource?.state;
@@ -2017,10 +2182,176 @@ export default function piWorkflows(pi: ExtensionAPI) {
2017
2182
  }
2018
2183
  return {
2019
2184
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
2020
- details: workflowStateSummary(state),
2185
+ details: workflowStateSummary(state, readWorkflowRun(state.runId) ?? undefined),
2021
2186
  };
2022
2187
  };
2023
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
+
2024
2355
  const resolveWaitingWorkflow = async (
2025
2356
  ctx: ExtensionContext,
2026
2357
  requestedRunId?: string,
@@ -2623,7 +2954,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2623
2954
 
2624
2955
  pi.registerCommand("workflow", {
2625
2956
  description:
2626
- "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",
2627
2958
  getArgumentCompletions: async (prefix: string) => {
2628
2959
  const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
2629
2960
  const items = [
@@ -2633,6 +2964,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
2633
2964
  { value: "resume", label: "resume" },
2634
2965
  { value: "cancel", label: "cancel" },
2635
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" },
2636
2970
  ].filter((item) => item.value.startsWith(prefix));
2637
2971
  return items.length > 0 ? items : null;
2638
2972
  },
@@ -2672,6 +3006,47 @@ export default function piWorkflows(pi: ExtensionAPI) {
2672
3006
  }
2673
3007
  return;
2674
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
+ }
2675
3050
  if (parsed.kind === "answer") {
2676
3051
  try {
2677
3052
  const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
@@ -2830,11 +3205,26 @@ export default function piWorkflows(pi: ExtensionAPI) {
2830
3205
  },
2831
3206
  });
2832
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
+
2833
3223
  pi.registerTool({
2834
3224
  name: "workflow",
2835
3225
  label: "Workflow",
2836
3226
  description: [
2837
- "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.",
2838
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.",
2839
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.",
2840
3230
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
@@ -2842,89 +3232,121 @@ export default function piWorkflows(pi: ExtensionAPI) {
2842
3232
  ].join(" "),
2843
3233
  parameters: WorkflowToolParameters,
2844
3234
  async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
2845
- const params = parseWorkflowToolInput(rawParams);
2846
- let control: WorkflowControlResult;
2847
- switch (params.action) {
2848
- case "list":
2849
- control = await listWorkflowControl(ctx, params.offset);
2850
- break;
2851
- case "start":
2852
- control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
2853
- break;
2854
- case "status":
2855
- control = await statusWorkflowControl(ctx, params.runId);
2856
- break;
2857
- case "pause":
2858
- control = pauseWorkflowControl(ctx);
2859
- break;
2860
- case "resume":
2861
- control = resumeWorkflowControl(ctx);
2862
- break;
2863
- case "cancel":
2864
- control = await cancelWorkflowControl(ctx, "agent", params.runId);
2865
- break;
2866
- case "answer": {
2867
- const waiting = await resolveWaitingWorkflow(ctx, params.runId);
2868
- const parent = readWorkflowRun(waiting.parentRunId);
2869
- if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2870
- throw new Error(
2871
- "This checkpoint requires a verified human answer from Pi UI or a configured decision channel.",
2872
- );
2873
- }
2874
- control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
2875
- parentRunId: waiting.parentRunId,
2876
- });
2877
- break;
2878
- }
2879
- case "update": {
2880
- if (!activeRun) throw new Error("No workflow step is active.");
2881
- const receipt = await activeRun.engine.publishUpdate(
2882
- params.step,
2883
- params.attempt,
2884
- params.update,
2885
- toolCallId,
2886
- );
2887
- return {
2888
- content: [
2889
- { type: "text", text: "Workflow update published; the step remains active." },
2890
- ],
2891
- details: { action: "update", ...receipt },
2892
- };
2893
- }
2894
- case "submit": {
2895
- if (!activeRun) {
2896
- if (
2897
- lastExpiredAttempt?.contract.attemptId === params.attempt &&
2898
- lastExpiredAttempt.contract.nodeId === params.step
2899
- ) {
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) {
2900
3261
  throw new Error(
2901
- `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.",
2902
3263
  );
2903
3264
  }
2904
- 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;
2905
3269
  }
2906
- // Flush the conversation into SQLite before accepting, so the
2907
- // attempt range includes the assistant message carrying this call.
2908
- await activeRun.recorder?.record(ctx).catch(() => undefined);
2909
- await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
2910
- const result = await activeRun.executor.submit(
2911
- params.step,
2912
- params.attempt,
2913
- params.output,
2914
- );
2915
- if (!result.accepted) {
2916
- 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
+ };
2917
3343
  }
2918
- return {
2919
- content: [{ type: "text", text: result.message }],
2920
- details: { action: "submit", step: params.step, accepted: true },
2921
- };
2922
3344
  }
2923
- }
2924
- return {
2925
- content: [{ type: "text", text: control.message }],
2926
- details: control.details,
2927
- };
3345
+ return {
3346
+ content: [{ type: "text", text: control.message }],
3347
+ details: control.details,
3348
+ };
3349
+ });
2928
3350
  },
2929
3351
  });
2930
3352
 
@@ -2950,6 +3372,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
2950
3372
  controllerContext = ctx;
2951
3373
  if (herdrEnabled) void refreshHerdrCapability(ctx);
2952
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);
2953
3391
  try {
2954
3392
  await reloadDecisionChannels(ctx);
2955
3393
  } catch {
@@ -3070,6 +3508,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
3070
3508
 
3071
3509
  pi.on("agent_settled", async (_event, ctx) => {
3072
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
+ }
3073
3519
  if (activeRun === null) {
3074
3520
  try {
3075
3521
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
@@ -3091,7 +3537,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
3091
3537
  const run = activeRun;
3092
3538
  if (!run) {
3093
3539
  turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
3094
- presentationPending = null;
3540
+ await followUpCoordinator?.synchronize(ctx, false).catch(() => undefined);
3095
3541
  runSyncPass(ctx);
3096
3542
  return;
3097
3543
  }
@@ -3146,6 +3592,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
3146
3592
  controllerContext = null;
3147
3593
  runQueueStore?.close();
3148
3594
  runQueueStore = null;
3595
+ followUpCoordinator?.clear();
3596
+ followUpCoordinator = null;
3597
+ followUpStore?.close();
3598
+ followUpStore = null;
3149
3599
  presentationPending = null;
3150
3600
  clearWidgetTimer();
3151
3601
  stopWidgetTicker();