@osolmaz/pi-workflows 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +64 -19
  2. package/dist/builtins/autodoc.workflow.d.ts +58 -0
  3. package/dist/builtins/autodoc.workflow.js +266 -0
  4. package/dist/builtins/autodoc.workflow.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +353 -0
  6. package/dist/builtins/autoimplement.workflow.js +961 -0
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -0
  8. package/dist/builtins/autoplan.workflow.d.ts +58 -0
  9. package/dist/builtins/autoplan.workflow.js +190 -0
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +9 -1
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -0
  14. package/dist/builtins/index.js +7 -0
  15. package/dist/builtins/index.js.map +1 -0
  16. package/dist/builtins/monitor.workflow.d.ts +29 -3
  17. package/dist/builtins/monitor.workflow.js +325 -13
  18. package/dist/builtins/monitor.workflow.js.map +1 -1
  19. package/dist/builtins/plan-approval.workflow.d.ts +83 -0
  20. package/dist/builtins/plan-approval.workflow.js +148 -0
  21. package/dist/builtins/plan-approval.workflow.js.map +1 -0
  22. package/dist/builtins/plan-presentation.d.ts +7 -0
  23. package/dist/builtins/plan-presentation.js +44 -0
  24. package/dist/builtins/plan-presentation.js.map +1 -0
  25. package/dist/extension/decision-channels.d.ts +132 -0
  26. package/dist/extension/decision-channels.js +1082 -0
  27. package/dist/extension/decision-channels.js.map +1 -0
  28. package/dist/extension/index.d.ts +1 -0
  29. package/dist/extension/index.js +426 -11
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/widget.js +26 -2
  32. package/dist/extension/widget.js.map +1 -1
  33. package/dist/extension/workflow-tool.d.ts +1 -37
  34. package/dist/extension/workflow-tool.js +1 -42
  35. package/dist/extension/workflow-tool.js.map +1 -1
  36. package/dist/host/rpc-bridge.js +4 -21
  37. package/dist/host/rpc-bridge.js.map +1 -1
  38. package/dist/render/graph-render.js +46 -10
  39. package/dist/render/graph-render.js.map +1 -1
  40. package/dist/viewer/render.js +36 -2
  41. package/dist/viewer/render.js.map +1 -1
  42. package/dist/workflows/catalog.d.ts +1 -0
  43. package/dist/workflows/catalog.js +6 -0
  44. package/dist/workflows/catalog.js.map +1 -1
  45. package/dist/workflows/composition.d.ts +45 -0
  46. package/dist/workflows/composition.js +485 -0
  47. package/dist/workflows/composition.js.map +1 -0
  48. package/dist/workflows/decision-presentation.d.ts +18 -0
  49. package/dist/workflows/decision-presentation.js +417 -0
  50. package/dist/workflows/decision-presentation.js.map +1 -0
  51. package/dist/workflows/decision.d.ts +11 -5
  52. package/dist/workflows/decision.js.map +1 -1
  53. package/dist/workflows/definition.d.ts +22 -3
  54. package/dist/workflows/definition.js +46 -3
  55. package/dist/workflows/definition.js.map +1 -1
  56. package/dist/workflows/engine.d.ts +2 -1
  57. package/dist/workflows/engine.js +199 -18
  58. package/dist/workflows/engine.js.map +1 -1
  59. package/dist/workflows/graph.js +8 -6
  60. package/dist/workflows/graph.js.map +1 -1
  61. package/dist/workflows/human-decision.d.ts +87 -0
  62. package/dist/workflows/human-decision.js +583 -0
  63. package/dist/workflows/human-decision.js.map +1 -0
  64. package/dist/workflows/index.d.ts +5 -2
  65. package/dist/workflows/index.js +4 -1
  66. package/dist/workflows/index.js.map +1 -1
  67. package/dist/workflows/loader.d.ts +5 -4
  68. package/dist/workflows/loader.js +118 -18
  69. package/dist/workflows/loader.js.map +1 -1
  70. package/dist/workflows/schema.d.ts +3 -1
  71. package/dist/workflows/schema.js +64 -2
  72. package/dist/workflows/schema.js.map +1 -1
  73. package/dist/workflows/store.js +39 -2
  74. package/dist/workflows/store.js.map +1 -1
  75. package/dist/workflows/tool-input.d.ts +72 -0
  76. package/dist/workflows/tool-input.js +141 -0
  77. package/dist/workflows/tool-input.js.map +1 -0
  78. package/dist/workflows/types.d.ts +300 -2
  79. package/docs/CONTROLLERS.md +1 -1
  80. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  81. package/docs/HUMAN_DECISIONS.md +371 -0
  82. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  83. package/docs/MONITOR.md +46 -18
  84. package/docs/WORKFLOW_COMPOSITION.md +333 -0
  85. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  86. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  87. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  88. package/docs/plans/2026-08-19-workflow-composition-plan.md +308 -0
  89. package/docs/run-bundles.md +45 -10
  90. package/docs/workflows.md +106 -15
  91. package/examples/workflows/approved-plan.workflow.ts +58 -0
  92. package/examples/workflows/autoimplement.workflow.ts +1 -92
  93. package/examples/workflows/autoplan.workflow.ts +1 -0
  94. package/examples/workflows/human-decision.workflow.ts +62 -0
  95. package/herdr-plugin.toml +1 -1
  96. package/package.json +6 -1
  97. package/schemas/decision-presentation-v1.schema.json +83 -0
  98. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  99. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  100. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  101. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  102. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  103. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  104. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  105. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  106. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  107. package/schemas/human-decision-request-v1.schema.json +59 -0
  108. package/schemas/human-decision-request-v2.schema.json +69 -0
  109. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  110. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  111. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  112. package/skills/autodoc/SKILL.md +43 -0
  113. package/skills/autoimplement/SKILL.md +58 -0
  114. package/skills/autoimplement/agents/openai.yaml +4 -0
  115. package/skills/autoplan/SKILL.md +25 -0
  116. package/skills/monitor/SKILL.md +8 -1
  117. package/skills/pi-workflows/SKILL.md +5 -3
  118. package/src/builtins/autodoc.workflow.ts +325 -0
  119. package/src/builtins/autoimplement.workflow.ts +1123 -0
  120. package/src/builtins/autoplan.workflow.ts +231 -0
  121. package/src/builtins/catalog.ts +9 -1
  122. package/src/builtins/index.ts +32 -0
  123. package/src/builtins/monitor.workflow.ts +396 -15
  124. package/src/builtins/plan-approval.workflow.ts +190 -0
  125. package/src/builtins/plan-presentation.ts +57 -0
  126. package/src/extension/decision-channels.ts +1533 -0
  127. package/src/extension/index.ts +499 -9
  128. package/src/extension/widget.ts +39 -2
  129. package/src/extension/workflow-tool.ts +5 -81
  130. package/src/host/rpc-bridge.ts +7 -30
  131. package/src/render/graph-render.ts +54 -5
  132. package/src/viewer/render.ts +56 -3
  133. package/src/workflows/catalog.ts +7 -0
  134. package/src/workflows/composition.ts +642 -0
  135. package/src/workflows/decision-presentation.ts +501 -0
  136. package/src/workflows/decision.ts +12 -5
  137. package/src/workflows/definition.ts +118 -8
  138. package/src/workflows/engine.ts +257 -20
  139. package/src/workflows/graph.ts +8 -6
  140. package/src/workflows/human-decision.ts +819 -0
  141. package/src/workflows/index.ts +89 -0
  142. package/src/workflows/loader.ts +186 -18
  143. package/src/workflows/schema.ts +79 -2
  144. package/src/workflows/store.ts +45 -2
  145. package/src/workflows/tool-input.ts +202 -0
  146. package/src/workflows/types.ts +374 -2
  147. package/examples/workflows/elegant-solution.workflow.ts +0 -95
@@ -3,19 +3,23 @@ import path from "node:path";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
5
  import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
6
+ import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
6
7
  import { WorkflowEngine } from "../workflows/engine.js";
7
8
  import { ClaimLostError, errorMessage, isClaimLostError, TimeoutError, } from "../workflows/errors.js";
9
+ import { HumanDecisionStore, createHumanDecisionAttemptId, validateHumanDecisionResponse, } from "../workflows/human-decision.js";
8
10
  import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
9
11
  import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
10
12
  import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/progress.js";
11
13
  import { createRunId, listRunBundles, readLastTraceEvent, readRunBundle, WorkflowRunStore, createDefinitionSnapshot, } from "../workflows/store.js";
12
14
  import { PiControllerHost, parseControllerArgs, } from "./controller-host.js";
15
+ import { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
13
16
  import { ConversationStepExecutor } from "./executor.js";
14
17
  import { HerdrWorkflowViewer, parseViewerPlacement, PIW_SHORTCUT, PIW_SHORTCUT_HINT, VIEWER_PLACEMENTS, } from "./herdr-viewer.js";
15
18
  import { SessionRecorder } from "./recorder.js";
16
19
  import { registerWorkflowAgentStepMessageRenderer, WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA, WORKFLOW_AGENT_STEP_MESSAGE_TYPE, } from "./step-message.js";
17
20
  import { buildWidgetView } from "./widget.js";
18
- import { WorkflowToolParameters } from "./workflow-tool.js";
21
+ import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.js";
22
+ export { PiDecisionChannel, TelegramDecisionChannel, audienceChannels, createTelegramChannels, decisionConfigDir, loadDecisionChannelConfig, verifyTelegramTokenFile, writeDecisionChannelProfile, } from "./decision-channels.js";
19
23
  const RUN_CLAIM_LEASE_MS = 30_000;
20
24
  const RUN_CLAIM_RENEW_MS = 10_000;
21
25
  const RUN_SYNC_POLL_MS = 3_000;
@@ -42,6 +46,16 @@ class PresentationSupersededError extends Error {
42
46
  }
43
47
  class PresentationTimeoutError extends Error {
44
48
  }
49
+ function humanDecisionRequest(value) {
50
+ const schema = value !== null && typeof value === "object"
51
+ ? value.schema
52
+ : undefined;
53
+ if (schema !== "pi-workflows.human-decision-request.v1" &&
54
+ schema !== "pi-workflows.human-decision-request.v2") {
55
+ return null;
56
+ }
57
+ return value;
58
+ }
45
59
  /** Parse `/workflow` arguments. Exported for tests. */
46
60
  export function parseWorkflowArgs(args) {
47
61
  const trimmed = args.trim();
@@ -145,6 +159,16 @@ export default function piWorkflows(pi) {
145
159
  // One runner identity per session; it names this session in run claims.
146
160
  const runnerId = randomUUID();
147
161
  let runQueueStore = null;
162
+ let decisionChannelConfig = null;
163
+ let telegramDecisionChannels = new Map();
164
+ const activePiDecisionChannels = new Map();
165
+ const settleHumanDecisionChannels = async (decision) => {
166
+ const piChannel = activePiDecisionChannels.get(decision.decisionId);
167
+ await Promise.allSettled([
168
+ ...(piChannel === undefined ? [] : [piChannel.settle(decision)]),
169
+ ...[...telegramDecisionChannels.values()].map(async (channel) => channel.settle(decision)),
170
+ ]);
171
+ };
148
172
  const migrationBlockedRuns = new Set();
149
173
  const ensureRunQueueStore = (cwd) => {
150
174
  runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
@@ -154,6 +178,8 @@ export default function piWorkflows(pi) {
154
178
  // Run events remain an audit feed and never enter a conversation.
155
179
  let syncArmed = false;
156
180
  let runSyncTimer = null;
181
+ let decisionRecoveryTimer = null;
182
+ let decisionRecoveryActive = false;
157
183
  const recordRunEvent = (event) => {
158
184
  try {
159
185
  runQueueStore?.recordRunEvent({ ...event, runnerId });
@@ -548,8 +574,11 @@ export default function piWorkflows(pi) {
548
574
  if (state.status === "waiting" && run.childKey === undefined) {
549
575
  lastWaitingRunId = run.runId;
550
576
  }
577
+ const pendingDecision = humanDecisionRequest(state.finalOutput);
551
578
  const summary = state.status === "waiting" && state.waitingOn
552
- ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
579
+ ? pendingDecision === null
580
+ ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
581
+ : `Workflow ${state.workflowName} is waiting for a verified human decision: ${pendingDecision.title} (run ${state.runId})`
553
582
  : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""}`;
554
583
  notify(ctx, summary, state.status === "completed" ? "info" : "warning");
555
584
  try {
@@ -568,6 +597,24 @@ export default function piWorkflows(pi) {
568
597
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
569
598
  }
570
599
  void presentRun(ctx, run, state);
600
+ if (pendingDecision !== null && state.status === "waiting") {
601
+ const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
602
+ let available = false;
603
+ if (ctx.mode === "tui" && channels.includes("pi")) {
604
+ available = true;
605
+ queueMicrotask(() => void promptHumanDecision(ctx, pendingDecision));
606
+ }
607
+ for (const channelId of channels) {
608
+ const channel = telegramDecisionChannels.get(channelId);
609
+ if (channel !== undefined) {
610
+ available = true;
611
+ queueMicrotask(() => void channel.deliver(humanDecisionChannelRequest(pendingDecision)));
612
+ }
613
+ }
614
+ if (!available) {
615
+ notify(ctx, `Human decision ${pendingDecision.title} remains waiting because its audience has no available channel.`, "warning");
616
+ }
617
+ }
571
618
  };
572
619
  const startRun = async (ctx, ref, input, options = {}) => {
573
620
  if (options.signal?.aborted) {
@@ -784,6 +831,9 @@ export default function piWorkflows(pi) {
784
831
  : engine.continueRun(workflow, options.parentRunId, input, {
785
832
  workflowSource,
786
833
  runId,
834
+ ...(options.humanDecision !== undefined
835
+ ? { humanDecision: options.humanDecision }
836
+ : {}),
787
837
  }))
788
838
  .then((result) => finishRun(ctx, run, result))
789
839
  .catch(async (error) => {
@@ -1025,7 +1075,35 @@ export default function piWorkflows(pi) {
1025
1075
  const result = await listWorkflowControl(ctx);
1026
1076
  notify(ctx, result.message, result.level);
1027
1077
  };
1028
- const cancelWorkflowControl = (ctx) => {
1078
+ const cancelHumanDecision = async (request) => {
1079
+ const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1080
+ await store.cancel(request, "cancelled");
1081
+ const cancellation = await store.readCancellation(request.decisionId);
1082
+ if (cancellation !== null)
1083
+ await settleHumanDecisionChannels(cancellation);
1084
+ lastWaitingRunId = null;
1085
+ };
1086
+ const findOwnedWaitingHumanDecision = async (ctx) => {
1087
+ const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1088
+ const sessionId = ctx.sessionManager.getSessionId();
1089
+ const owned = new Set(rows
1090
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1091
+ .map((row) => row.runId));
1092
+ const continued = new Set(rows.map((row) => row.parentRunId).filter((parent) => parent !== null));
1093
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1094
+ for (const bundle of bundles) {
1095
+ if (bundle.state.status !== "waiting" ||
1096
+ !owned.has(bundle.state.runId) ||
1097
+ continued.has(bundle.state.runId)) {
1098
+ continue;
1099
+ }
1100
+ const request = humanDecisionRequest(bundle.state.finalOutput);
1101
+ if (request !== null)
1102
+ return { state: bundle.state, request };
1103
+ }
1104
+ return null;
1105
+ };
1106
+ const cancelWorkflowControl = async (ctx) => {
1029
1107
  if (activeRun) {
1030
1108
  const workflowName = activeRun.workflowName;
1031
1109
  const runId = activeRun.runId;
@@ -1045,6 +1123,16 @@ export default function piWorkflows(pi) {
1045
1123
  }
1046
1124
  if (widgetSource) {
1047
1125
  const { state } = widgetSource;
1126
+ const request = humanDecisionRequest(state.finalOutput);
1127
+ if (state.status === "waiting" && request !== null) {
1128
+ await cancelHumanDecision(request);
1129
+ clearWidgetTimer();
1130
+ clearWidget(ctx);
1131
+ return {
1132
+ message: `Cancelled the pending human decision for workflow ${state.workflowName}.`,
1133
+ details: { action: "cancel", workflowName: state.workflowName, runId: state.runId },
1134
+ };
1135
+ }
1048
1136
  clearWidgetTimer();
1049
1137
  clearWidget(ctx);
1050
1138
  const detail = state.status === "waiting" && state.waitingOn
@@ -1055,6 +1143,18 @@ export default function piWorkflows(pi) {
1055
1143
  details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
1056
1144
  };
1057
1145
  }
1146
+ const recovered = await findOwnedWaitingHumanDecision(ctx);
1147
+ if (recovered !== null) {
1148
+ await cancelHumanDecision(recovered.request);
1149
+ return {
1150
+ message: `Cancelled the pending human decision for workflow ${recovered.state.workflowName}.`,
1151
+ details: {
1152
+ action: "cancel",
1153
+ workflowName: recovered.state.workflowName,
1154
+ runId: recovered.state.runId,
1155
+ },
1156
+ };
1157
+ }
1058
1158
  return {
1059
1159
  message: "No workflow is running.",
1060
1160
  details: { action: "cancel", active: false },
@@ -1156,7 +1256,7 @@ export default function piWorkflows(pi) {
1156
1256
  details: workflowStateSummary(state),
1157
1257
  };
1158
1258
  };
1159
- const resolveWaitingWorkflow = async (ctx, requestedRunId) => {
1259
+ const resolveWaitingWorkflow = async (ctx, requestedRunId, allowOtherSession = false) => {
1160
1260
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1161
1261
  if (parentRunId === null) {
1162
1262
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
@@ -1175,7 +1275,8 @@ export default function piWorkflows(pi) {
1175
1275
  throw new Error("No workflow is waiting for an answer.");
1176
1276
  }
1177
1277
  const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1178
- if (queueRecord?.originSessionId !== null &&
1278
+ if (!allowOtherSession &&
1279
+ queueRecord?.originSessionId !== null &&
1179
1280
  queueRecord?.originSessionId !== undefined &&
1180
1281
  queueRecord.originSessionId !== ctx.sessionManager.getSessionId()) {
1181
1282
  throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
@@ -1196,14 +1297,98 @@ export default function piWorkflows(pi) {
1196
1297
  : parent.state.workflowSource.path,
1197
1298
  };
1198
1299
  };
1199
- const answerWorkflowControl = async (ctx, input, requestedRunId) => {
1200
- const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1201
- const continued = await startRun(ctx, waiting.workflowRef, input, {
1300
+ const answerWorkflowControl = async (ctx, input, requestedRunId, verified) => {
1301
+ const waiting = await resolveWaitingWorkflow(ctx, requestedRunId, verified !== undefined);
1302
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
1303
+ if (parent === null)
1304
+ throw new Error(`Workflow run ${waiting.parentRunId} is unreadable.`);
1305
+ const request = humanDecisionRequest(parent.state.finalOutput);
1306
+ let accepted;
1307
+ let continuationInput = input;
1308
+ let continuationRunId;
1309
+ if (request !== null) {
1310
+ if (verified !== undefined &&
1311
+ (verified.request.decisionId !== request.decisionId ||
1312
+ verified.request.requestDigest !== request.requestDigest)) {
1313
+ throw new Error("Verified human answer does not match the waiting decision.");
1314
+ }
1315
+ const response = validateHumanDecisionResponse(request, verified?.response ?? input);
1316
+ const submission = {
1317
+ decisionId: request.decisionId,
1318
+ requestDigest: request.requestDigest,
1319
+ ...response,
1320
+ source: verified?.source ?? {
1321
+ channel: "pi",
1322
+ actorId: ctx.sessionManager.getSessionId(),
1323
+ eventId: createHumanDecisionAttemptId(),
1324
+ },
1325
+ idempotencyKey: verified?.idempotencyKey ?? createHumanDecisionAttemptId(),
1326
+ };
1327
+ const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1328
+ const acceptance = await store.accept(request, submission);
1329
+ if (acceptance.status === "conflict") {
1330
+ throw new Error("That human decision was already answered differently.");
1331
+ }
1332
+ accepted = acceptance.decision;
1333
+ continuationInput = parent.state.input;
1334
+ continuationRunId = `continuation-${request.decisionId.slice("decision-".length)}`;
1335
+ }
1336
+ if (request !== null && accepted !== undefined && verified !== undefined) {
1337
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(waiting.parentRunId);
1338
+ const currentSessionId = ctx.sessionManager.getSessionId();
1339
+ if (queueRecord === undefined ||
1340
+ (queueRecord.originSessionId !== null && queueRecord.originSessionId !== currentSessionId)) {
1341
+ await settleHumanDecisionChannels(accepted);
1342
+ return {
1343
+ message: "Human decision accepted; its owning Pi session will continue the workflow.",
1344
+ details: {
1345
+ action: "answer",
1346
+ parentRunId: waiting.parentRunId,
1347
+ accepted: true,
1348
+ continuationPending: true,
1349
+ },
1350
+ };
1351
+ }
1352
+ }
1353
+ const decisionStore = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1354
+ if (request !== null && continuationRunId !== undefined && accepted !== undefined) {
1355
+ await decisionStore.recordContinuation(request.decisionId, {
1356
+ schema: "pi-workflows.human-decision-continuation.v1",
1357
+ decisionId: request.decisionId,
1358
+ requestDigest: request.requestDigest,
1359
+ parentRunId: waiting.parentRunId,
1360
+ runId: continuationRunId,
1361
+ createdAt: accepted.acceptedAt,
1362
+ });
1363
+ }
1364
+ if (continuationRunId !== undefined) {
1365
+ const existingContinuation = await readRunBundle(new WorkflowRunStore().runDirFor(continuationRunId));
1366
+ if (existingContinuation !== null) {
1367
+ if (accepted !== undefined)
1368
+ await settleHumanDecisionChannels(accepted);
1369
+ lastWaitingRunId = null;
1370
+ return {
1371
+ message: `Human decision already continued as ${continuationRunId}.`,
1372
+ details: {
1373
+ action: "answer",
1374
+ parentRunId: waiting.parentRunId,
1375
+ runId: continuationRunId,
1376
+ adopted: true,
1377
+ },
1378
+ };
1379
+ }
1380
+ }
1381
+ const continued = await startRun(ctx, waiting.workflowRef, continuationInput, {
1202
1382
  parentRunId: waiting.parentRunId,
1383
+ ...(accepted !== undefined ? { humanDecision: accepted } : {}),
1384
+ ...(continuationRunId !== undefined ? { runId: continuationRunId } : {}),
1203
1385
  });
1204
1386
  if (continued === undefined) {
1205
1387
  throw new Error("Could not start the checkpoint continuation.");
1206
1388
  }
1389
+ if (request !== null && accepted !== undefined) {
1390
+ await settleHumanDecisionChannels(accepted);
1391
+ }
1207
1392
  lastWaitingRunId = null;
1208
1393
  return {
1209
1394
  message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
@@ -1214,6 +1399,148 @@ export default function piWorkflows(pi) {
1214
1399
  },
1215
1400
  };
1216
1401
  };
1402
+ async function promptHumanDecision(ctx, request) {
1403
+ if (activePiDecisionChannels.has(request.decisionId))
1404
+ return;
1405
+ const channel = new PiDecisionChannel({
1406
+ actorId: ctx.sessionManager.getSessionId(),
1407
+ ui: ctx.ui,
1408
+ store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
1409
+ onAnswer: async (answer) => {
1410
+ const result = await answerWorkflowControl(ctx, answer.response, request.runId, answer);
1411
+ notify(ctx, result.message, result.level);
1412
+ },
1413
+ });
1414
+ activePiDecisionChannels.set(request.decisionId, channel);
1415
+ try {
1416
+ await channel.deliver(humanDecisionChannelRequest(request));
1417
+ }
1418
+ catch (error) {
1419
+ notify(ctx, `Could not answer human decision: ${errorMessage(error)}`, "error");
1420
+ }
1421
+ finally {
1422
+ if (activePiDecisionChannels.get(request.decisionId) === channel) {
1423
+ activePiDecisionChannels.delete(request.decisionId);
1424
+ }
1425
+ }
1426
+ }
1427
+ const stopDecisionChannels = async () => {
1428
+ await Promise.allSettled([
1429
+ ...[...activePiDecisionChannels.values()].map(async (channel) => channel.stop()),
1430
+ ...[...telegramDecisionChannels.values()].map(async (channel) => channel.stop()),
1431
+ ]);
1432
+ activePiDecisionChannels.clear();
1433
+ telegramDecisionChannels.clear();
1434
+ decisionChannelConfig = null;
1435
+ };
1436
+ const reloadDecisionChannels = async (ctx) => {
1437
+ await stopDecisionChannels();
1438
+ const loaded = await loadDecisionChannelConfig();
1439
+ if (loaded === null)
1440
+ return;
1441
+ decisionChannelConfig = loaded.channels;
1442
+ telegramDecisionChannels = createTelegramChannels({
1443
+ config: loaded.channels,
1444
+ credentials: loaded.credentials,
1445
+ configDir: loaded.configDir,
1446
+ store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
1447
+ onAnswer: async (answer) => {
1448
+ await answerWorkflowControl(ctx, answer.response, answer.request.runId, answer);
1449
+ },
1450
+ });
1451
+ await Promise.all([...telegramDecisionChannels.values()].map(async (channel) => channel.start()));
1452
+ };
1453
+ const recoverHumanDecisions = async (ctx, deliverPending = true) => {
1454
+ const runStore = new WorkflowRunStore();
1455
+ const store = new HumanDecisionStore(runStore.outputRoot);
1456
+ const requests = (await store.listRequests()).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
1457
+ for (const request of requests) {
1458
+ const parent = await readRunBundle(runStore.runDirFor(request.runId));
1459
+ if (parent === null || parent.state.status !== "waiting")
1460
+ continue;
1461
+ const currentRequest = humanDecisionRequest(parent.state.finalOutput);
1462
+ if (currentRequest === null ||
1463
+ currentRequest.decisionId !== request.decisionId ||
1464
+ currentRequest.requestDigest !== request.requestDigest) {
1465
+ continue;
1466
+ }
1467
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(request.runId);
1468
+ const ownedBySession = queueRecord !== undefined &&
1469
+ (queueRecord.originSessionId === null ||
1470
+ queueRecord.originSessionId === ctx.sessionManager.getSessionId());
1471
+ const accepted = await store.readAccepted(request.decisionId);
1472
+ if (accepted === null) {
1473
+ let cancellation = await store.readCancellation(request.decisionId);
1474
+ if (cancellation === null &&
1475
+ request.expiresAt !== undefined &&
1476
+ Date.parse(request.expiresAt) <= Date.now()) {
1477
+ await store.cancel(request, "expired");
1478
+ cancellation = await store.readCancellation(request.decisionId);
1479
+ }
1480
+ if (cancellation !== null) {
1481
+ await settleHumanDecisionChannels(cancellation);
1482
+ continue;
1483
+ }
1484
+ if (!deliverPending || !ownedBySession)
1485
+ continue;
1486
+ const channels = audienceChannels(decisionChannelConfig, request.audience);
1487
+ for (const channelId of channels) {
1488
+ const channel = telegramDecisionChannels.get(channelId);
1489
+ if (channel !== undefined)
1490
+ await channel.deliver(humanDecisionChannelRequest(request));
1491
+ }
1492
+ if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
1493
+ lastWaitingRunId = request.runId;
1494
+ queueMicrotask(() => void promptHumanDecision(ctx, request));
1495
+ }
1496
+ continue;
1497
+ }
1498
+ if (!ownedBySession)
1499
+ continue;
1500
+ const runId = `continuation-${request.decisionId.slice("decision-".length)}`;
1501
+ const continuation = (await store.readContinuation(request.decisionId)) ?? {
1502
+ schema: "pi-workflows.human-decision-continuation.v1",
1503
+ decisionId: request.decisionId,
1504
+ requestDigest: request.requestDigest,
1505
+ parentRunId: request.runId,
1506
+ runId,
1507
+ createdAt: accepted.acceptedAt,
1508
+ };
1509
+ await store.recordContinuation(request.decisionId, continuation);
1510
+ const existing = await readRunBundle(runStore.runDirFor(continuation.runId));
1511
+ if (existing === null && activeRun === null) {
1512
+ if (parent.state.workflowSource === undefined)
1513
+ continue;
1514
+ const workflowRef = parent.state.workflowSource.kind === "builtin"
1515
+ ? `builtin:${parent.state.workflowSource.id}`
1516
+ : parent.state.workflowSource.path;
1517
+ await startRun(ctx, workflowRef, parent.state.input, {
1518
+ parentRunId: request.runId,
1519
+ humanDecision: accepted,
1520
+ runId: continuation.runId,
1521
+ quiet: true,
1522
+ });
1523
+ }
1524
+ await settleHumanDecisionChannels(accepted);
1525
+ if (activeRun !== null)
1526
+ break;
1527
+ }
1528
+ };
1529
+ const startDecisionRecovery = (ctx) => {
1530
+ if (decisionRecoveryTimer !== null)
1531
+ return;
1532
+ decisionRecoveryTimer = setInterval(() => {
1533
+ if (decisionRecoveryActive || sessionClosed)
1534
+ return;
1535
+ decisionRecoveryActive = true;
1536
+ void recoverHumanDecisions(ctx, false)
1537
+ .catch(() => undefined)
1538
+ .finally(() => {
1539
+ decisionRecoveryActive = false;
1540
+ });
1541
+ }, 1_000);
1542
+ decisionRecoveryTimer.unref?.();
1543
+ };
1217
1544
  const startWorkflowControl = async (ctx, ref, input) => {
1218
1545
  if (activeRun !== null) {
1219
1546
  throw new Error(`A workflow is already running: ${activeRun.workflowName}.`);
@@ -1312,7 +1639,7 @@ export default function piWorkflows(pi) {
1312
1639
  return;
1313
1640
  }
1314
1641
  if (parsed.kind === "cancel") {
1315
- const result = cancelWorkflowControl(ctx);
1642
+ const result = await cancelWorkflowControl(ctx);
1316
1643
  notify(ctx, result.message, result.level);
1317
1644
  return;
1318
1645
  }
@@ -1358,6 +1685,69 @@ export default function piWorkflows(pi) {
1358
1685
  }
1359
1686
  },
1360
1687
  });
1688
+ pi.registerCommand("workflow-channel", {
1689
+ description: "Configure or reload private human decision channels",
1690
+ handler: async (args, ctx) => {
1691
+ const action = args.trim() || "status";
1692
+ try {
1693
+ if (action === "reload") {
1694
+ await reloadDecisionChannels(ctx);
1695
+ notify(ctx, "Human decision channels reloaded.");
1696
+ return;
1697
+ }
1698
+ if (action === "status") {
1699
+ notify(ctx, decisionChannelConfig === null
1700
+ ? "Human decisions use the Pi channel only."
1701
+ : `Human decision channels are configured for ${Object.keys(decisionChannelConfig.audiences).length} audience(s).`);
1702
+ return;
1703
+ }
1704
+ if (action !== "setup") {
1705
+ throw new Error("Use /workflow-channel status, setup, or reload.");
1706
+ }
1707
+ if (!ctx.hasUI || ctx.mode !== "tui") {
1708
+ throw new Error("Channel setup requires interactive Pi TUI mode.");
1709
+ }
1710
+ const tokenFile = await ctx.ui.input("Absolute path to the mode-0600 Telegram token file", "");
1711
+ if (tokenFile === undefined)
1712
+ return;
1713
+ const audience = await ctx.ui.input("Logical audience", "operator");
1714
+ if (audience === undefined)
1715
+ return;
1716
+ const profile = await ctx.ui.input("Private Telegram profile name", "default");
1717
+ if (profile === undefined)
1718
+ return;
1719
+ const credential = await ctx.ui.input("Private credential reference name", "telegram");
1720
+ if (credential === undefined)
1721
+ return;
1722
+ const users = await ctx.ui.input("Allowed numeric Telegram user IDs, comma separated", "");
1723
+ if (users === undefined)
1724
+ return;
1725
+ const chats = await ctx.ui.input("Allowed numeric Telegram chat IDs, comma separated", "");
1726
+ if (chats === undefined)
1727
+ return;
1728
+ await verifyTelegramTokenFile(tokenFile.trim());
1729
+ await writeDecisionChannelProfile({
1730
+ audience: audience.trim(),
1731
+ profile: profile.trim(),
1732
+ credential: credential.trim(),
1733
+ tokenFile: tokenFile.trim(),
1734
+ allowedUserIds: users
1735
+ .split(",")
1736
+ .map((value) => value.trim())
1737
+ .filter(Boolean),
1738
+ allowedChatIds: chats
1739
+ .split(",")
1740
+ .map((value) => value.trim())
1741
+ .filter(Boolean),
1742
+ });
1743
+ await reloadDecisionChannels(ctx);
1744
+ notify(ctx, "The private human decision channel profile was verified and installed.");
1745
+ }
1746
+ catch (error) {
1747
+ notify(ctx, `Human decision channel setup failed: ${errorMessage(error)}`, "error");
1748
+ }
1749
+ },
1750
+ });
1361
1751
  pi.registerCommand("controller", {
1362
1752
  description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
1363
1753
  getArgumentCompletions: async (prefix) => {
@@ -1431,7 +1821,8 @@ export default function piWorkflows(pi) {
1431
1821
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
1432
1822
  ].join(" "),
1433
1823
  parameters: WorkflowToolParameters,
1434
- async execute(toolCallId, params, _signal, _onUpdate, ctx) {
1824
+ async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
1825
+ const params = parseWorkflowToolInput(rawParams);
1435
1826
  let control;
1436
1827
  switch (params.action) {
1437
1828
  case "list":
@@ -1450,10 +1841,14 @@ export default function piWorkflows(pi) {
1450
1841
  control = resumeWorkflowControl(ctx);
1451
1842
  break;
1452
1843
  case "cancel":
1453
- control = cancelWorkflowControl(ctx);
1844
+ control = await cancelWorkflowControl(ctx);
1454
1845
  break;
1455
1846
  case "answer": {
1456
1847
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
1848
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
1849
+ if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
1850
+ throw new Error("This checkpoint requires a verified human answer from Pi UI or a configured decision channel.");
1851
+ }
1457
1852
  control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1458
1853
  parentRunId: waiting.parentRunId,
1459
1854
  });
@@ -1534,6 +1929,13 @@ export default function piWorkflows(pi) {
1534
1929
  catch (error) {
1535
1930
  notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1536
1931
  }
1932
+ try {
1933
+ await reloadDecisionChannels(ctx);
1934
+ }
1935
+ catch {
1936
+ await stopDecisionChannels();
1937
+ notify(ctx, "Could not start human decision channels because the private channel configuration is invalid or unavailable.", "warning");
1938
+ }
1537
1939
  try {
1538
1940
  syncArmed = true;
1539
1941
  startRunSync(ctx);
@@ -1541,6 +1943,13 @@ export default function piWorkflows(pi) {
1541
1943
  catch {
1542
1944
  // Session sync is best-effort; runs themselves never depend on it.
1543
1945
  }
1946
+ try {
1947
+ await recoverHumanDecisions(ctx);
1948
+ startDecisionRecovery(ctx);
1949
+ }
1950
+ catch (error) {
1951
+ notify(ctx, `Could not recover human decisions: ${errorMessage(error)}`, "warning");
1952
+ }
1544
1953
  try {
1545
1954
  await resumeParkedRun(ctx);
1546
1955
  }
@@ -1673,7 +2082,13 @@ export default function piWorkflows(pi) {
1673
2082
  clearInterval(runSyncTimer);
1674
2083
  runSyncTimer = null;
1675
2084
  }
2085
+ if (decisionRecoveryTimer !== null) {
2086
+ clearInterval(decisionRecoveryTimer);
2087
+ decisionRecoveryTimer = null;
2088
+ }
2089
+ decisionRecoveryActive = false;
1676
2090
  syncArmed = false;
2091
+ await stopDecisionChannels();
1677
2092
  await controllerHost?.close().catch(() => undefined);
1678
2093
  controllerHost = undefined;
1679
2094
  controllerContext = null;