@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
@@ -2,9 +2,10 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
4
4
  import { SqliteControllerStore } from "../controllers/index.js";
5
+ import { canonicalJson } from "../state/json.js";
5
6
  import { compositionMetadata } from "../workflows/composition.js";
6
7
  import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
7
- import { WorkflowEngine } from "../workflows/engine.js";
8
+ import { WorkflowEngine, workflowIdentityMismatch } from "../workflows/engine.js";
8
9
  import { BuiltinWorkflowRevisionChangedError, ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
9
10
  import { HumanDecisionStore, createHumanDecisionAttemptId, validateHumanDecisionResponse, } from "../workflows/human-decision.js";
10
11
  import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
@@ -15,6 +16,7 @@ import { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTel
15
16
  import { DeferredTurnCoordinator, } from "./deferred-turn-coordinator.js";
16
17
  import { buildDeferredTurnContent, createDeferredTurnDescriptor, DEFERRED_TURN_MESSAGE_TYPE, deferredTurnMessageDetails, deferredTurnMessageId, deferredTurnSourceEventId, } from "./deferred-turn.js";
17
18
  import { ConversationStepExecutor } from "./executor.js";
19
+ import { FollowUpCoordinator, findSettledPresentationEntries } from "./follow-up-coordinator.js";
18
20
  import { HerdrWorkflowViewer, parseViewerPlacement, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
19
21
  import { SessionRecorder } from "./recorder.js";
20
22
  import { recoverAssistantStep, registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
@@ -58,7 +60,7 @@ function humanDecisionRequest(value) {
58
60
  return value;
59
61
  }
60
62
  function definitionDigest(snapshot) {
61
- return `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
63
+ return `sha256:${createHash("sha256").update(canonicalJson(snapshot)).digest("hex")}`;
62
64
  }
63
65
  function launchSourceIdentity(workflow, root) {
64
66
  return {
@@ -121,6 +123,28 @@ export function parseWorkflowArgs(args) {
121
123
  if (trimmed === "status") {
122
124
  return { kind: "status" };
123
125
  }
126
+ if (trimmed.startsWith("change-settings ")) {
127
+ const text = trimmed.slice("change-settings".length).trim();
128
+ try {
129
+ return { kind: "change-settings", patch: JSON.parse(text) };
130
+ }
131
+ catch (error) {
132
+ throw new Error(`change-settings requires a JSON Patch array: ${error instanceof Error ? error.message : String(error)}`);
133
+ }
134
+ }
135
+ if (trimmed.startsWith("queue-follow-up ")) {
136
+ const prompt = trimmed.slice("queue-follow-up".length).trim();
137
+ if (prompt.length === 0)
138
+ throw new Error("queue-follow-up requires a prompt");
139
+ return { kind: "queue-follow-up", prompt };
140
+ }
141
+ if (trimmed.startsWith("remove-follow-up ")) {
142
+ const followUpId = trimmed.slice("remove-follow-up".length).trim();
143
+ if (!/^follow-up-[a-f0-9]{40}$/u.test(followUpId)) {
144
+ throw new Error("remove-follow-up requires one valid follow-up id");
145
+ }
146
+ return { kind: "remove-follow-up", followUpId };
147
+ }
124
148
  if (trimmed.startsWith("status ")) {
125
149
  const runId = trimmed.slice("status".length).trim();
126
150
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
@@ -176,7 +200,7 @@ export function parseWorkflowArgs(args) {
176
200
  }
177
201
  return { kind: "run", ref, input: rest.length > 0 ? { task: rest } : {} };
178
202
  }
179
- function workflowStateSummary(state) {
203
+ function workflowStateSummary(state, controls) {
180
204
  const error = state.error === undefined
181
205
  ? undefined
182
206
  : state.error.length <= MAX_STATUS_ERROR_CHARS
@@ -196,6 +220,32 @@ function workflowStateSummary(state) {
196
220
  ...(state.waitingOn !== undefined ? { waitingOn: state.waitingOn } : {}),
197
221
  ...(state.startedAt !== undefined ? { startedAt: state.startedAt } : {}),
198
222
  ...(state.finishedAt !== undefined ? { finishedAt: state.finishedAt } : {}),
223
+ ...(controls !== undefined
224
+ ? {
225
+ settingsScopes: (controls.settingsScopes ?? []).map((scope) => ({
226
+ scopeId: scope.scopeId,
227
+ mountPath: scope.mountPath,
228
+ invocation: scope.invocation,
229
+ changeNumber: scope.changeNumber,
230
+ settingsHash: scope.settingsHash,
231
+ activeRunId: scope.activeRunId,
232
+ })),
233
+ followUps: controls.followUpQueue === null || controls.followUpQueue === undefined
234
+ ? null
235
+ : {
236
+ presentationState: controls.followUpQueue.presentationState,
237
+ items: controls.followUpQueue.followUps.map((followUp) => ({
238
+ followUpId: followUp.followUpId,
239
+ order: followUp.order,
240
+ state: followUp.state,
241
+ source: followUp.source,
242
+ ...(followUp.sessionEntryId !== undefined
243
+ ? { sessionEntryId: followUp.sessionEntryId }
244
+ : {}),
245
+ })),
246
+ },
247
+ }
248
+ : {}),
199
249
  ...(error !== undefined ? { error } : {}),
200
250
  };
201
251
  }
@@ -313,6 +363,7 @@ export default function piWorkflows(pi) {
313
363
  }, { triggerTurn: true, deliverAs: "followUp" });
314
364
  },
315
365
  }, idle);
366
+ void followUpCoordinator?.synchronize(ctx, activeRun !== null).catch(() => undefined);
316
367
  }
317
368
  catch {
318
369
  // Delivery retries on the next poll. It never affects workflow execution.
@@ -345,6 +396,8 @@ export default function piWorkflows(pi) {
345
396
  let runGeneration = 0;
346
397
  let presentationAbort = null;
347
398
  let presentationPending = null;
399
+ let followUpStore = null;
400
+ let followUpCoordinator = null;
348
401
  let controllerHost;
349
402
  let controllerContext = null;
350
403
  const branchIntentResolution = (intentId) => {
@@ -376,6 +429,38 @@ export default function piWorkflows(pi) {
376
429
  branchResolution: branchIntentResolution,
377
430
  leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
378
431
  });
432
+ const ensureFollowUpDelivery = () => {
433
+ if (followUpStore !== null && followUpCoordinator !== null)
434
+ return;
435
+ followUpStore?.close();
436
+ followUpStore = new WorkflowRunStore();
437
+ followUpCoordinator = new FollowUpCoordinator(followUpStore, `follow-up-${runnerId}`, (text) => pi.sendUserMessage(text));
438
+ };
439
+ const settlePresentationFromBranch = (ctx, runId, unavailableReason) => {
440
+ if (followUpStore === null ||
441
+ followUpStore.readFollowUpQueue(runId)?.presentationState !== "pending") {
442
+ return false;
443
+ }
444
+ const settled = findSettledPresentationEntries(ctx.sessionManager.getBranch(), runId);
445
+ if (settled !== undefined) {
446
+ followUpStore.settleFollowUpPresentation({
447
+ runId,
448
+ state: "settled",
449
+ presentationEntryId: settled.presentationEntryId,
450
+ assistantEntryId: settled.assistantEntryId,
451
+ });
452
+ return true;
453
+ }
454
+ if (unavailableReason !== undefined) {
455
+ followUpStore.settleFollowUpPresentation({
456
+ runId,
457
+ state: "unavailable",
458
+ reason: unavailableReason,
459
+ });
460
+ return true;
461
+ }
462
+ return false;
463
+ };
379
464
  // UI updates are best-effort: a captured ctx becomes stale after session
380
465
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
381
466
  // A workflow finishing right as the session goes away must not crash pi.
@@ -688,6 +773,7 @@ export default function piWorkflows(pi) {
688
773
  return;
689
774
  }
690
775
  if (instructions === undefined || instructions.trim().length === 0) {
776
+ settlePresentationFromBranch(ctx, run.runId, "No result presentation was available");
691
777
  if (run.abortProvenance !== undefined) {
692
778
  makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
693
779
  }
@@ -698,13 +784,14 @@ export default function piWorkflows(pi) {
698
784
  targetSessionId: originSessionId(ctx, run.runId),
699
785
  resolution: "presentation",
700
786
  send: (turnIntentId) => {
701
- presentationPending = run.generation;
787
+ presentationPending = { generation: run.generation, runId: run.runId };
702
788
  pi.sendMessage({
703
789
  customType: PRESENTATION_MESSAGE_TYPE,
704
790
  content: buildPresentationMessage(instructions, state),
705
791
  display: false,
706
792
  details: {
707
793
  schema: PRESENTATION_MESSAGE_SCHEMA,
794
+ runId: run.runId,
708
795
  ...(turnIntentId === undefined ? {} : { turnIntentId }),
709
796
  },
710
797
  }, {
@@ -715,7 +802,7 @@ export default function piWorkflows(pi) {
715
802
  }, ctx.isIdle() && systemTurnAbort === null);
716
803
  }
717
804
  catch (error) {
718
- if (presentationPending === run.generation) {
805
+ if (presentationPending?.generation === run.generation) {
719
806
  presentationPending = null;
720
807
  }
721
808
  if (error instanceof PresentationSupersededError ||
@@ -727,6 +814,7 @@ export default function piWorkflows(pi) {
727
814
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
728
815
  : errorMessage(error);
729
816
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
817
+ settlePresentationFromBranch(ctx, run.runId, message);
730
818
  if (run.abortProvenance !== undefined) {
731
819
  makeRunTurnIntentEligible(ctx, run, state.status, message);
732
820
  }
@@ -875,6 +963,7 @@ export default function piWorkflows(pi) {
875
963
  if (options.signal?.aborted) {
876
964
  throw options.signal.reason ?? new Error("Workflow startup aborted");
877
965
  }
966
+ ensureFollowUpDelivery();
878
967
  if (activeRun) {
879
968
  if (!options.quiet) {
880
969
  notify(ctx, `A workflow is already running: ${activeRun.workflowName}. Use /workflow cancel first.`, "error");
@@ -1309,7 +1398,65 @@ export default function piWorkflows(pi) {
1309
1398
  if (controllerHost !== undefined) {
1310
1399
  return controllerHost;
1311
1400
  }
1312
- controllerHost = await PiControllerHost.create({ cwd: ctx.cwd, startChild });
1401
+ controllerHost = await PiControllerHost.create({
1402
+ cwd: ctx.cwd,
1403
+ startChild,
1404
+ workflowControls: {
1405
+ changeSettings: async (request) => {
1406
+ const result = await changeWorkflowSettings(ctx, {
1407
+ runId: request.runId,
1408
+ ...(request.scopeId !== undefined ? { scopeId: request.scopeId } : {}),
1409
+ ...(request.expectedChangeNumber !== undefined
1410
+ ? { expectedChangeNumber: request.expectedChangeNumber }
1411
+ : {}),
1412
+ patch: request.patch,
1413
+ requestId: request.actorRequestKey,
1414
+ actor: "controller",
1415
+ actorId: request.controllerResourceUid,
1416
+ source: "controller-request",
1417
+ });
1418
+ return {
1419
+ runId: request.runId,
1420
+ scopeId: String(result.details.scopeId),
1421
+ changeNumber: Number(result.details.changeNumber),
1422
+ adopted: result.details.adopted === true,
1423
+ };
1424
+ },
1425
+ queueFollowUp: async (request) => {
1426
+ const result = await queueWorkflowFollowUp(ctx, {
1427
+ runId: request.runId,
1428
+ prompt: request.prompt,
1429
+ requestId: request.actorRequestKey,
1430
+ actor: "controller",
1431
+ actorId: request.controllerResourceUid,
1432
+ source: "controller-request",
1433
+ });
1434
+ return {
1435
+ runId: request.runId,
1436
+ followUpId: String(result.details.followUpId),
1437
+ order: Number(result.details.order),
1438
+ state: "queued",
1439
+ adopted: result.details.adopted === true,
1440
+ };
1441
+ },
1442
+ removeFollowUp: async (request) => {
1443
+ const result = await removeWorkflowFollowUp(ctx, {
1444
+ runId: request.runId,
1445
+ followUpId: request.followUpId,
1446
+ actor: "controller",
1447
+ actorId: request.controllerResourceUid,
1448
+ source: "controller-request",
1449
+ });
1450
+ return {
1451
+ runId: request.runId,
1452
+ followUpId: String(result.details.followUpId),
1453
+ order: Number(result.details.order),
1454
+ state: String(result.details.state),
1455
+ adopted: false,
1456
+ };
1457
+ },
1458
+ },
1459
+ });
1313
1460
  controllerHost?.start();
1314
1461
  updateControllerStatus(ctx);
1315
1462
  return controllerHost;
@@ -1573,7 +1720,7 @@ export default function piWorkflows(pi) {
1573
1720
  const { state } = bundle;
1574
1721
  return {
1575
1722
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1576
- details: workflowStateSummary(state),
1723
+ details: workflowStateSummary(state, bundle),
1577
1724
  };
1578
1725
  }
1579
1726
  const state = activeRun?.lastState ?? widgetSource?.state;
@@ -1591,9 +1738,136 @@ export default function piWorkflows(pi) {
1591
1738
  }
1592
1739
  return {
1593
1740
  message: `Workflow ${state.workflowName} is ${state.status} (run ${state.runId}).`,
1594
- details: workflowStateSummary(state),
1741
+ details: workflowStateSummary(state, readWorkflowRun(state.runId) ?? undefined),
1595
1742
  };
1596
1743
  };
1744
+ const resolveMutableWorkflow = async (ctx, requestedRunId) => {
1745
+ const runId = requestedRunId ?? activeRun?.runId ?? lastWaitingRunId ?? undefined;
1746
+ if (runId === undefined) {
1747
+ throw new Error("No running, parked, or waiting workflow is available for changes.");
1748
+ }
1749
+ const loaded = readWorkflowRun(runId);
1750
+ if (loaded === null)
1751
+ throw new Error(`Workflow run not found: ${runId}`);
1752
+ if (originSessionId(ctx, runId) !== ctx.sessionManager.getSessionId()) {
1753
+ throw new Error("Workflow changes must come from the Pi session that owns the run.");
1754
+ }
1755
+ const source = loaded.state.workflowSource;
1756
+ if (source === undefined)
1757
+ throw new Error(`Workflow run ${runId} has no saved source`);
1758
+ const ref = source.kind === "builtin" ? `builtin:${source.id}` : source.path;
1759
+ const resolved = await resolveWorkflowRef(ref, { cwd: ctx.cwd }, builtinWorkflowCatalog);
1760
+ if (workflowIdentityMismatch(loaded.state, resolved.definition, resolved.source)) {
1761
+ throw new Error(`Workflow source changed since run ${runId} started`);
1762
+ }
1763
+ return { runId, workflow: resolved.definition, store: new WorkflowRunStore() };
1764
+ };
1765
+ const changeWorkflowSettings = async (ctx, options) => {
1766
+ const target = await resolveMutableWorkflow(ctx, options.runId);
1767
+ try {
1768
+ const scopes = target.store.listSettingsScopes(target.runId);
1769
+ const scopeId = options.scopeId ??
1770
+ (activeRun?.runId === target.runId ? activeRun.engine.activeSettingsScopeId : undefined) ??
1771
+ (scopes.length === 1 ? scopes[0]?.scopeId : undefined);
1772
+ if (scopeId === undefined) {
1773
+ throw new Error("Specify scopeId because this workflow has more than one settings scope.");
1774
+ }
1775
+ const scope = target.store.getSettingsScope(scopeId);
1776
+ if (scope === undefined || scope.activeRunId !== target.runId) {
1777
+ throw new Error(`Workflow settings scope not found for run ${target.runId}: ${scopeId}`);
1778
+ }
1779
+ const definition = scope.mountPath === ""
1780
+ ? target.workflow.settings
1781
+ : compositionMetadata(target.workflow)?.scopes[scope.mountPath]?.settings;
1782
+ if (definition === undefined) {
1783
+ throw new Error(`Workflow scope ${scopeId} does not declare editable settings.`);
1784
+ }
1785
+ const result = await target.store.changeSettings(definition, {
1786
+ runId: target.runId,
1787
+ scopeId,
1788
+ requestId: options.requestId,
1789
+ ...(options.expectedChangeNumber !== undefined
1790
+ ? { expectedChangeNumber: options.expectedChangeNumber }
1791
+ : {}),
1792
+ actor: {
1793
+ type: options.actor,
1794
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
1795
+ },
1796
+ source: options.source,
1797
+ patch: options.patch,
1798
+ });
1799
+ renderWidget(ctx);
1800
+ return {
1801
+ message: `Workflow settings changed to number ${result.scope.changeNumber}.`,
1802
+ details: {
1803
+ action: "change-settings",
1804
+ runId: target.runId,
1805
+ scopeId,
1806
+ changeNumber: result.scope.changeNumber,
1807
+ adopted: result.adopted,
1808
+ },
1809
+ };
1810
+ }
1811
+ finally {
1812
+ target.store.close();
1813
+ }
1814
+ };
1815
+ const queueWorkflowFollowUp = async (ctx, options) => {
1816
+ const target = await resolveMutableWorkflow(ctx, options.runId);
1817
+ try {
1818
+ const result = target.store.queueFollowUp({
1819
+ runId: target.runId,
1820
+ requestId: options.requestId,
1821
+ targetSessionId: ctx.sessionManager.getSessionId(),
1822
+ actor: {
1823
+ type: options.actor,
1824
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
1825
+ },
1826
+ source: options.source,
1827
+ prompt: options.prompt,
1828
+ });
1829
+ return {
1830
+ message: `Queued workflow follow-up ${result.followUp.order}.`,
1831
+ details: {
1832
+ action: "queue-follow-up",
1833
+ runId: target.runId,
1834
+ followUpId: result.followUp.followUpId,
1835
+ order: result.followUp.order,
1836
+ adopted: result.adopted,
1837
+ },
1838
+ };
1839
+ }
1840
+ finally {
1841
+ target.store.close();
1842
+ }
1843
+ };
1844
+ const removeWorkflowFollowUp = async (ctx, options) => {
1845
+ const target = await resolveMutableWorkflow(ctx, options.runId);
1846
+ try {
1847
+ const followUp = target.store.removeFollowUp({
1848
+ runId: target.runId,
1849
+ followUpId: options.followUpId,
1850
+ actor: {
1851
+ type: options.actor,
1852
+ id: options.actorId ?? ctx.sessionManager.getSessionId(),
1853
+ },
1854
+ source: options.source,
1855
+ });
1856
+ return {
1857
+ message: `Removed workflow follow-up ${followUp.order}.`,
1858
+ details: {
1859
+ action: "remove-follow-up",
1860
+ runId: target.runId,
1861
+ followUpId: followUp.followUpId,
1862
+ order: followUp.order,
1863
+ state: followUp.state,
1864
+ },
1865
+ };
1866
+ }
1867
+ finally {
1868
+ target.store.close();
1869
+ }
1870
+ };
1597
1871
  const resolveWaitingWorkflow = async (ctx, requestedRunId, allowOtherSession = false) => {
1598
1872
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1599
1873
  if (parentRunId === null) {
@@ -2115,7 +2389,7 @@ export default function piWorkflows(pi) {
2115
2389
  },
2116
2390
  });
2117
2391
  pi.registerCommand("workflow", {
2118
- description: "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
2392
+ description: "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",
2119
2393
  getArgumentCompletions: async (prefix) => {
2120
2394
  const discovered = await discoverWorkflows({ cwd: process.cwd() }, builtinWorkflowCatalog);
2121
2395
  const items = [
@@ -2125,6 +2399,9 @@ export default function piWorkflows(pi) {
2125
2399
  { value: "resume", label: "resume" },
2126
2400
  { value: "cancel", label: "cancel" },
2127
2401
  { value: "answer", label: "answer" },
2402
+ { value: "change-settings", label: "change-settings" },
2403
+ { value: "queue-follow-up", label: "queue-follow-up" },
2404
+ { value: "remove-follow-up", label: "remove-follow-up" },
2128
2405
  ].filter((item) => item.value.startsWith(prefix));
2129
2406
  return items.length > 0 ? items : null;
2130
2407
  },
@@ -2166,6 +2443,50 @@ export default function piWorkflows(pi) {
2166
2443
  }
2167
2444
  return;
2168
2445
  }
2446
+ if (parsed.kind === "change-settings") {
2447
+ try {
2448
+ const result = await changeWorkflowSettings(ctx, {
2449
+ patch: parsed.patch,
2450
+ requestId: `interactive-${randomUUID()}`,
2451
+ actor: "human",
2452
+ source: "interactive-command",
2453
+ });
2454
+ notify(ctx, result.message, result.level);
2455
+ }
2456
+ catch (error) {
2457
+ notify(ctx, errorMessage(error), "error");
2458
+ }
2459
+ return;
2460
+ }
2461
+ if (parsed.kind === "queue-follow-up") {
2462
+ try {
2463
+ const result = await queueWorkflowFollowUp(ctx, {
2464
+ prompt: parsed.prompt,
2465
+ requestId: `interactive-${randomUUID()}`,
2466
+ actor: "human",
2467
+ source: "interactive-command",
2468
+ });
2469
+ notify(ctx, result.message, result.level);
2470
+ }
2471
+ catch (error) {
2472
+ notify(ctx, errorMessage(error), "error");
2473
+ }
2474
+ return;
2475
+ }
2476
+ if (parsed.kind === "remove-follow-up") {
2477
+ try {
2478
+ const result = await removeWorkflowFollowUp(ctx, {
2479
+ followUpId: parsed.followUpId,
2480
+ actor: "human",
2481
+ source: "interactive-command",
2482
+ });
2483
+ notify(ctx, result.message, result.level);
2484
+ }
2485
+ catch (error) {
2486
+ notify(ctx, errorMessage(error), "error");
2487
+ }
2488
+ return;
2489
+ }
2169
2490
  if (parsed.kind === "answer") {
2170
2491
  try {
2171
2492
  const result = await answerWorkflowControl(ctx, parsed.input, parsed.runId);
@@ -2315,11 +2636,26 @@ export default function piWorkflows(pi) {
2315
2636
  }
2316
2637
  },
2317
2638
  });
2639
+ let workflowToolTail = Promise.resolve();
2640
+ const withWorkflowToolOrder = async (operation) => {
2641
+ const prior = workflowToolTail;
2642
+ let release;
2643
+ workflowToolTail = new Promise((resolve) => {
2644
+ release = resolve;
2645
+ });
2646
+ await prior;
2647
+ try {
2648
+ return await operation();
2649
+ }
2650
+ finally {
2651
+ release?.();
2652
+ }
2653
+ };
2318
2654
  pi.registerTool({
2319
2655
  name: "workflow",
2320
2656
  label: "Workflow",
2321
2657
  description: [
2322
- "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2658
+ "List, start, inspect, change settings, queue or remove follow-ups, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2323
2659
  "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.",
2324
2660
  "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.",
2325
2661
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
@@ -2327,75 +2663,107 @@ export default function piWorkflows(pi) {
2327
2663
  ].join(" "),
2328
2664
  parameters: WorkflowToolParameters,
2329
2665
  async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
2330
- const params = parseWorkflowToolInput(rawParams);
2331
- let control;
2332
- switch (params.action) {
2333
- case "list":
2334
- control = await listWorkflowControl(ctx, params.offset);
2335
- break;
2336
- case "start":
2337
- control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
2338
- break;
2339
- case "status":
2340
- control = await statusWorkflowControl(ctx, params.runId);
2341
- break;
2342
- case "pause":
2343
- control = pauseWorkflowControl(ctx);
2344
- break;
2345
- case "resume":
2346
- control = resumeWorkflowControl(ctx);
2347
- break;
2348
- case "cancel":
2349
- control = await cancelWorkflowControl(ctx, "agent", params.runId);
2350
- break;
2351
- case "answer": {
2352
- const waiting = await resolveWaitingWorkflow(ctx, params.runId);
2353
- const parent = readWorkflowRun(waiting.parentRunId);
2354
- if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2355
- throw new Error("This checkpoint requires a verified human answer from Pi UI or a configured decision channel.");
2356
- }
2357
- control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
2358
- parentRunId: waiting.parentRunId,
2359
- });
2360
- break;
2361
- }
2362
- case "update": {
2363
- if (!activeRun)
2364
- throw new Error("No workflow step is active.");
2365
- const receipt = await activeRun.engine.publishUpdate(params.step, params.attempt, params.update, toolCallId);
2366
- return {
2367
- content: [
2368
- { type: "text", text: "Workflow update published; the step remains active." },
2369
- ],
2370
- details: { action: "update", ...receipt },
2371
- };
2372
- }
2373
- case "submit": {
2374
- if (!activeRun) {
2375
- if (lastExpiredAttempt?.contract.attemptId === params.attempt &&
2376
- lastExpiredAttempt.contract.nodeId === params.step) {
2377
- throw new Error(`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`);
2666
+ return await withWorkflowToolOrder(async () => {
2667
+ const params = parseWorkflowToolInput(rawParams);
2668
+ let control;
2669
+ switch (params.action) {
2670
+ case "list":
2671
+ control = await listWorkflowControl(ctx, params.offset);
2672
+ break;
2673
+ case "start":
2674
+ control = await queueToolLaunch(ctx, params.workflow, params.input ?? {});
2675
+ break;
2676
+ case "status":
2677
+ control = await statusWorkflowControl(ctx, params.runId);
2678
+ break;
2679
+ case "pause":
2680
+ control = pauseWorkflowControl(ctx);
2681
+ break;
2682
+ case "resume":
2683
+ control = resumeWorkflowControl(ctx);
2684
+ break;
2685
+ case "cancel":
2686
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2687
+ break;
2688
+ case "answer": {
2689
+ const waiting = await resolveWaitingWorkflow(ctx, params.runId);
2690
+ const parent = readWorkflowRun(waiting.parentRunId);
2691
+ if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2692
+ throw new Error("This checkpoint requires a verified human answer from Pi UI or a configured decision channel.");
2378
2693
  }
2379
- throw new Error("No workflow step is waiting for output.");
2694
+ control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
2695
+ parentRunId: waiting.parentRunId,
2696
+ });
2697
+ break;
2698
+ }
2699
+ case "change-settings":
2700
+ control = await changeWorkflowSettings(ctx, {
2701
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
2702
+ ...(params.scopeId !== undefined ? { scopeId: params.scopeId } : {}),
2703
+ ...(params.expectedChangeNumber !== undefined
2704
+ ? { expectedChangeNumber: params.expectedChangeNumber }
2705
+ : {}),
2706
+ patch: params.patch,
2707
+ requestId: `tool-${toolCallId}`,
2708
+ actor: "session",
2709
+ source: "workflow-tool",
2710
+ });
2711
+ break;
2712
+ case "queue-follow-up":
2713
+ control = await queueWorkflowFollowUp(ctx, {
2714
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
2715
+ prompt: params.prompt,
2716
+ requestId: `tool-${toolCallId}`,
2717
+ actor: "session",
2718
+ source: "workflow-tool",
2719
+ });
2720
+ break;
2721
+ case "remove-follow-up":
2722
+ control = await removeWorkflowFollowUp(ctx, {
2723
+ ...(params.runId !== undefined ? { runId: params.runId } : {}),
2724
+ followUpId: params.followUpId,
2725
+ actor: "session",
2726
+ source: "workflow-tool",
2727
+ });
2728
+ break;
2729
+ case "update": {
2730
+ if (!activeRun)
2731
+ throw new Error("No workflow step is active.");
2732
+ const receipt = await activeRun.engine.publishUpdate(params.step, params.attempt, params.update, toolCallId);
2733
+ return {
2734
+ content: [
2735
+ { type: "text", text: "Workflow update published; the step remains active." },
2736
+ ],
2737
+ details: { action: "update", ...receipt },
2738
+ };
2380
2739
  }
2381
- // Flush the conversation into SQLite before accepting, so the
2382
- // attempt range includes the assistant message carrying this call.
2383
- await activeRun.recorder?.record(ctx).catch(() => undefined);
2384
- await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
2385
- const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
2386
- if (!result.accepted) {
2387
- throw new Error(result.message);
2740
+ case "submit": {
2741
+ if (!activeRun) {
2742
+ if (lastExpiredAttempt?.contract.attemptId === params.attempt &&
2743
+ lastExpiredAttempt.contract.nodeId === params.step) {
2744
+ throw new Error(`Workflow step ${JSON.stringify(params.step)} attempt ${JSON.stringify(params.attempt)} ${lastExpiredAttempt.reason}; its output is no longer accepted.`);
2745
+ }
2746
+ throw new Error("No workflow step is waiting for output.");
2747
+ }
2748
+ // Flush the conversation into SQLite before accepting, so the
2749
+ // attempt range includes the assistant message carrying this call.
2750
+ await activeRun.recorder?.record(ctx).catch(() => undefined);
2751
+ await activeRun.recorder?.synchronize(ctx).catch(() => undefined);
2752
+ const result = await activeRun.executor.submit(params.step, params.attempt, params.output);
2753
+ if (!result.accepted) {
2754
+ throw new Error(result.message);
2755
+ }
2756
+ return {
2757
+ content: [{ type: "text", text: result.message }],
2758
+ details: { action: "submit", step: params.step, accepted: true },
2759
+ };
2388
2760
  }
2389
- return {
2390
- content: [{ type: "text", text: result.message }],
2391
- details: { action: "submit", step: params.step, accepted: true },
2392
- };
2393
2761
  }
2394
- }
2395
- return {
2396
- content: [{ type: "text", text: control.message }],
2397
- details: control.details,
2398
- };
2762
+ return {
2763
+ content: [{ type: "text", text: control.message }],
2764
+ details: control.details,
2765
+ };
2766
+ });
2399
2767
  },
2400
2768
  });
2401
2769
  if (herdrEnabled) {
@@ -2418,6 +2786,16 @@ export default function piWorkflows(pi) {
2418
2786
  if (herdrEnabled)
2419
2787
  void refreshHerdrCapability(ctx);
2420
2788
  ensureRunQueueStore(ctx.cwd);
2789
+ ensureFollowUpDelivery();
2790
+ const sessionFollowUpStore = followUpStore;
2791
+ const sessionFollowUpCoordinator = followUpCoordinator;
2792
+ if (sessionFollowUpStore === null || sessionFollowUpCoordinator === null) {
2793
+ throw new Error("Workflow follow-up delivery did not initialize");
2794
+ }
2795
+ for (const runId of sessionFollowUpStore.listPendingPresentations(ctx.sessionManager.getSessionId())) {
2796
+ settlePresentationFromBranch(ctx, runId, "The final workflow response did not settle before the extension restarted");
2797
+ }
2798
+ await sessionFollowUpCoordinator.synchronize(ctx, false).catch(() => undefined);
2421
2799
  try {
2422
2800
  await reloadDecisionChannels(ctx);
2423
2801
  }
@@ -2521,6 +2899,10 @@ export default function piWorkflows(pi) {
2521
2899
  });
2522
2900
  pi.on("agent_settled", async (_event, ctx) => {
2523
2901
  systemTurnAbort = null;
2902
+ if (presentationPending !== null) {
2903
+ settlePresentationFromBranch(ctx, presentationPending.runId, "The final workflow response settled without durable session evidence");
2904
+ presentationPending = null;
2905
+ }
2524
2906
  if (activeRun === null) {
2525
2907
  try {
2526
2908
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(ctx.sessionManager.getSessionId());
@@ -2537,7 +2919,7 @@ export default function piWorkflows(pi) {
2537
2919
  const run = activeRun;
2538
2920
  if (!run) {
2539
2921
  turnCoordinator.flushNatural(ctx.isIdle() && !sessionClosed && !runHeld());
2540
- presentationPending = null;
2922
+ await followUpCoordinator?.synchronize(ctx, false).catch(() => undefined);
2541
2923
  runSyncPass(ctx);
2542
2924
  return;
2543
2925
  }
@@ -2593,6 +2975,10 @@ export default function piWorkflows(pi) {
2593
2975
  controllerContext = null;
2594
2976
  runQueueStore?.close();
2595
2977
  runQueueStore = null;
2978
+ followUpCoordinator?.clear();
2979
+ followUpCoordinator = null;
2980
+ followUpStore?.close();
2981
+ followUpStore = null;
2596
2982
  presentationPending = null;
2597
2983
  clearWidgetTimer();
2598
2984
  stopWidgetTicker();