@osolmaz/pi-workflows 0.10.0 → 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 (125) hide show
  1. package/README.md +23 -12
  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 +210 -11
  6. package/dist/builtins/autoimplement.workflow.js +245 -13
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/{autodevise.workflow.d.ts → autoplan.workflow.d.ts} +9 -9
  9. package/dist/builtins/{autodevise.workflow.js → autoplan.workflow.js} +20 -20
  10. package/dist/builtins/autoplan.workflow.js.map +1 -0
  11. package/dist/builtins/catalog.js +8 -4
  12. package/dist/builtins/catalog.js.map +1 -1
  13. package/dist/builtins/index.d.ts +6 -3
  14. package/dist/builtins/index.js +5 -2
  15. package/dist/builtins/index.js.map +1 -1
  16. package/dist/builtins/monitor.workflow.d.ts +4 -0
  17. package/dist/builtins/monitor.workflow.js +134 -9
  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 +33 -8
  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/composition.js +15 -1
  43. package/dist/workflows/composition.js.map +1 -1
  44. package/dist/workflows/decision-presentation.d.ts +18 -0
  45. package/dist/workflows/decision-presentation.js +417 -0
  46. package/dist/workflows/decision-presentation.js.map +1 -0
  47. package/dist/workflows/engine.d.ts +2 -1
  48. package/dist/workflows/engine.js +86 -4
  49. package/dist/workflows/engine.js.map +1 -1
  50. package/dist/workflows/human-decision.d.ts +87 -0
  51. package/dist/workflows/human-decision.js +583 -0
  52. package/dist/workflows/human-decision.js.map +1 -0
  53. package/dist/workflows/index.d.ts +3 -1
  54. package/dist/workflows/index.js +2 -0
  55. package/dist/workflows/index.js.map +1 -1
  56. package/dist/workflows/schema.js +15 -0
  57. package/dist/workflows/schema.js.map +1 -1
  58. package/dist/workflows/store.js +7 -0
  59. package/dist/workflows/store.js.map +1 -1
  60. package/dist/workflows/tool-input.d.ts +72 -0
  61. package/dist/workflows/tool-input.js +141 -0
  62. package/dist/workflows/tool-input.js.map +1 -0
  63. package/dist/workflows/types.d.ts +223 -0
  64. package/docs/HUMAN_DECISIONS.md +371 -0
  65. package/docs/HUMAN_DECISION_PRESENTATIONS.md +323 -0
  66. package/docs/MONITOR.md +17 -6
  67. package/docs/WORKFLOW_COMPOSITION.md +13 -6
  68. package/docs/plans/2026-08-19-human-decision-gates-plan.md +296 -0
  69. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +172 -0
  70. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +45 -0
  71. package/docs/plans/2026-08-19-workflow-composition-plan.md +25 -17
  72. package/docs/run-bundles.md +23 -2
  73. package/docs/workflows.md +43 -5
  74. package/examples/workflows/approved-plan.workflow.ts +58 -0
  75. package/examples/workflows/autoplan.workflow.ts +1 -0
  76. package/examples/workflows/human-decision.workflow.ts +62 -0
  77. package/herdr-plugin.toml +1 -1
  78. package/package.json +2 -1
  79. package/schemas/decision-presentation-v1.schema.json +83 -0
  80. package/schemas/human-decision-accepted-v1.schema.json +44 -0
  81. package/schemas/human-decision-accepted-v2.schema.json +50 -0
  82. package/schemas/human-decision-answer-attempt-v1.schema.json +37 -0
  83. package/schemas/human-decision-cancellation-v1.schema.json +15 -0
  84. package/schemas/human-decision-continuation-v1.schema.json +16 -0
  85. package/schemas/human-decision-delivery-v1.schema.json +28 -0
  86. package/schemas/human-decision-delivery-v2.schema.json +36 -0
  87. package/schemas/human-decision-receipt-v1.schema.json +33 -0
  88. package/schemas/human-decision-receipt-v2.schema.json +39 -0
  89. package/schemas/human-decision-request-v1.schema.json +59 -0
  90. package/schemas/human-decision-request-v2.schema.json +69 -0
  91. package/schemas/human-decision-resolution-v1.schema.json +27 -0
  92. package/schemas/human-decision-resolution-v2.schema.json +27 -0
  93. package/schemas/human-decision-settlement-v1.schema.json +28 -0
  94. package/skills/autodoc/SKILL.md +43 -0
  95. package/skills/autoimplement/SKILL.md +58 -0
  96. package/skills/autoimplement/agents/openai.yaml +4 -0
  97. package/skills/autoplan/SKILL.md +25 -0
  98. package/skills/monitor/SKILL.md +3 -1
  99. package/skills/pi-workflows/SKILL.md +3 -3
  100. package/src/builtins/autodoc.workflow.ts +325 -0
  101. package/src/builtins/autoimplement.workflow.ts +281 -14
  102. package/src/builtins/{autodevise.workflow.ts → autoplan.workflow.ts} +32 -32
  103. package/src/builtins/catalog.ts +8 -4
  104. package/src/builtins/index.ts +25 -6
  105. package/src/builtins/monitor.workflow.ts +162 -8
  106. package/src/builtins/plan-approval.workflow.ts +190 -0
  107. package/src/builtins/plan-presentation.ts +57 -0
  108. package/src/extension/decision-channels.ts +1533 -0
  109. package/src/extension/index.ts +499 -9
  110. package/src/extension/widget.ts +39 -2
  111. package/src/extension/workflow-tool.ts +5 -81
  112. package/src/host/rpc-bridge.ts +7 -30
  113. package/src/render/graph-render.ts +40 -3
  114. package/src/viewer/render.ts +56 -3
  115. package/src/workflows/composition.ts +16 -1
  116. package/src/workflows/decision-presentation.ts +501 -0
  117. package/src/workflows/engine.ts +108 -4
  118. package/src/workflows/human-decision.ts +819 -0
  119. package/src/workflows/index.ts +69 -0
  120. package/src/workflows/schema.ts +17 -0
  121. package/src/workflows/store.ts +8 -0
  122. package/src/workflows/tool-input.ts +202 -0
  123. package/src/workflows/types.ts +265 -0
  124. package/dist/builtins/autodevise.workflow.js.map +0 -1
  125. package/examples/workflows/autodevise.workflow.ts +0 -1
@@ -6,6 +6,7 @@ import { builtinWorkflowCatalog } from "../builtins/catalog.js";
6
6
  import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
7
7
  import type { JsonObject } from "../controllers/types.js";
8
8
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
9
+ import { humanDecisionChannelRequest } from "../workflows/decision-presentation.js";
9
10
  import { WorkflowEngine } from "../workflows/engine.js";
10
11
  import {
11
12
  ClaimLostError,
@@ -13,6 +14,11 @@ import {
13
14
  isClaimLostError,
14
15
  TimeoutError,
15
16
  } from "../workflows/errors.js";
17
+ import {
18
+ HumanDecisionStore,
19
+ createHumanDecisionAttemptId,
20
+ validateHumanDecisionResponse,
21
+ } from "../workflows/human-decision.js";
16
22
  import { discoverWorkflows, resolveWorkflowRef } from "../workflows/loader.js";
17
23
  import { migrateLegacyWorkflowSources } from "../workflows/migrate-sources.js";
18
24
  import { appendProgressHistory, progressRecordsFromTrace } from "../workflows/progress.js";
@@ -25,7 +31,10 @@ import {
25
31
  createDefinitionSnapshot,
26
32
  } from "../workflows/store.js";
27
33
  import type {
34
+ AcceptedHumanDecision,
28
35
  AgentStepContract,
36
+ HumanDecisionRequest,
37
+ HumanDecisionSubmission,
29
38
  WorkflowDefinition,
30
39
  WorkflowDefinitionSnapshot,
31
40
  WorkflowRunResult,
@@ -37,6 +46,18 @@ import {
37
46
  parseControllerArgs,
38
47
  type PiChildWorkflowStarter,
39
48
  } from "./controller-host.js";
49
+ import {
50
+ PiDecisionChannel,
51
+ TelegramDecisionChannel,
52
+ audienceChannels,
53
+ createTelegramChannels,
54
+ loadDecisionChannelConfig,
55
+ verifyTelegramTokenFile,
56
+ writeDecisionChannelProfile,
57
+ type DecisionChannelConfig,
58
+ type HumanDecisionChannelAnswer,
59
+ type SettledHumanDecision,
60
+ } from "./decision-channels.js";
40
61
  import { ConversationStepExecutor } from "./executor.js";
41
62
  import {
42
63
  HerdrWorkflowViewer,
@@ -56,7 +77,27 @@ import {
56
77
  type WorkflowAgentStepMessageDetails,
57
78
  } from "./step-message.js";
58
79
  import { buildWidgetView } from "./widget.js";
59
- import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
80
+ import { parseWorkflowToolInput, WorkflowToolParameters } from "./workflow-tool.js";
81
+
82
+ export {
83
+ PiDecisionChannel,
84
+ TelegramDecisionChannel,
85
+ audienceChannels,
86
+ createTelegramChannels,
87
+ decisionConfigDir,
88
+ loadDecisionChannelConfig,
89
+ verifyTelegramTokenFile,
90
+ writeDecisionChannelProfile,
91
+ type DecisionChannelConfig,
92
+ type DecisionCredentialConfig,
93
+ type HumanDecisionChannel,
94
+ type HumanDecisionChannelAnswer,
95
+ type HumanDecisionDeliveryResult,
96
+ type LoadedDecisionChannelConfig,
97
+ type PiDecisionUi,
98
+ type SettledHumanDecision,
99
+ type TelegramFetch,
100
+ } from "./decision-channels.js";
60
101
 
61
102
  const RUN_CLAIM_LEASE_MS = 30_000;
62
103
  const RUN_CLAIM_RENEW_MS = 10_000;
@@ -115,6 +156,20 @@ type ActiveRun = {
115
156
  parentRunId?: string | undefined;
116
157
  };
117
158
 
159
+ function humanDecisionRequest(value: unknown): HumanDecisionRequest | null {
160
+ const schema =
161
+ value !== null && typeof value === "object"
162
+ ? (value as { schema?: unknown }).schema
163
+ : undefined;
164
+ if (
165
+ schema !== "pi-workflows.human-decision-request.v1" &&
166
+ schema !== "pi-workflows.human-decision-request.v2"
167
+ ) {
168
+ return null;
169
+ }
170
+ return value as HumanDecisionRequest;
171
+ }
172
+
118
173
  type StartRunOptions = {
119
174
  runId?: string;
120
175
  childKey?: string;
@@ -122,8 +177,10 @@ type StartRunOptions = {
122
177
  presentation?: boolean;
123
178
  quiet?: boolean;
124
179
  signal?: AbortSignal;
125
- /** Continue a checkpointed run: input becomes the answer payload. */
180
+ /** Continue a checkpointed run: input becomes the legacy answer payload. */
126
181
  parentRunId?: string;
182
+ /** Accepted verified-human answer for a protected decision continuation. */
183
+ humanDecision?: AcceptedHumanDecision;
127
184
  /** Resume a parked run at its stopped node instead of starting fresh. */
128
185
  resume?: boolean;
129
186
  /** An existing queue claim token, when the caller already claimed the run. */
@@ -266,6 +323,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
266
323
  // One runner identity per session; it names this session in run claims.
267
324
  const runnerId = randomUUID();
268
325
  let runQueueStore: SqliteControllerStore | null = null;
326
+ let decisionChannelConfig: DecisionChannelConfig | null = null;
327
+ let telegramDecisionChannels = new Map<string, TelegramDecisionChannel>();
328
+ const activePiDecisionChannels = new Map<string, PiDecisionChannel>();
329
+ const settleHumanDecisionChannels = async (decision: SettledHumanDecision): Promise<void> => {
330
+ const piChannel = activePiDecisionChannels.get(decision.decisionId);
331
+ await Promise.allSettled([
332
+ ...(piChannel === undefined ? [] : [piChannel.settle(decision)]),
333
+ ...[...telegramDecisionChannels.values()].map(async (channel) => channel.settle(decision)),
334
+ ]);
335
+ };
269
336
  const migrationBlockedRuns = new Set<string>();
270
337
  const ensureRunQueueStore = (cwd: string): SqliteControllerStore => {
271
338
  runQueueStore ??= new SqliteControllerStore(projectControllerStorePath(cwd));
@@ -276,6 +343,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
276
343
  // Run events remain an audit feed and never enter a conversation.
277
344
  let syncArmed = false;
278
345
  let runSyncTimer: ReturnType<typeof setInterval> | null = null;
346
+ let decisionRecoveryTimer: ReturnType<typeof setInterval> | null = null;
347
+ let decisionRecoveryActive = false;
279
348
 
280
349
  const recordRunEvent = (event: {
281
350
  runId: string;
@@ -734,9 +803,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
734
803
  if (state.status === "waiting" && run.childKey === undefined) {
735
804
  lastWaitingRunId = run.runId;
736
805
  }
806
+ const pendingDecision = humanDecisionRequest(state.finalOutput);
737
807
  const summary =
738
808
  state.status === "waiting" && state.waitingOn
739
- ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
809
+ ? pendingDecision === null
810
+ ? `Workflow ${state.workflowName} parked at checkpoint ${state.waitingOn} — answer with /workflow answer <json> (run ${state.runId})`
811
+ : `Workflow ${state.workflowName} is waiting for a verified human decision: ${pendingDecision.title} (run ${state.runId})`
740
812
  : `Workflow ${state.workflowName} ${state.status} (run ${state.runId})${
741
813
  state.error !== undefined ? `: ${state.error.slice(0, MAX_STATUS_ERROR_CHARS)}` : ""
742
814
  }`;
@@ -757,6 +829,28 @@ export default function piWorkflows(pi: ExtensionAPI) {
757
829
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
758
830
  }
759
831
  void presentRun(ctx, run, state);
832
+ if (pendingDecision !== null && state.status === "waiting") {
833
+ const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
834
+ let available = false;
835
+ if (ctx.mode === "tui" && channels.includes("pi")) {
836
+ available = true;
837
+ queueMicrotask(() => void promptHumanDecision(ctx, pendingDecision));
838
+ }
839
+ for (const channelId of channels) {
840
+ const channel = telegramDecisionChannels.get(channelId);
841
+ if (channel !== undefined) {
842
+ available = true;
843
+ queueMicrotask(() => void channel.deliver(humanDecisionChannelRequest(pendingDecision)));
844
+ }
845
+ }
846
+ if (!available) {
847
+ notify(
848
+ ctx,
849
+ `Human decision ${pendingDecision.title} remains waiting because its audience has no available channel.`,
850
+ "warning",
851
+ );
852
+ }
853
+ }
760
854
  };
761
855
 
762
856
  const startRun = async (
@@ -1004,6 +1098,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
1004
1098
  : engine.continueRun(workflow, options.parentRunId, input, {
1005
1099
  workflowSource,
1006
1100
  runId,
1101
+ ...(options.humanDecision !== undefined
1102
+ ? { humanDecision: options.humanDecision }
1103
+ : {}),
1007
1104
  })
1008
1105
  )
1009
1106
  .then((result) => finishRun(ctx, run, result))
@@ -1279,7 +1376,43 @@ export default function piWorkflows(pi: ExtensionAPI) {
1279
1376
  notify(ctx, result.message, result.level);
1280
1377
  };
1281
1378
 
1282
- const cancelWorkflowControl = (ctx: ExtensionContext): WorkflowControlResult => {
1379
+ const cancelHumanDecision = async (request: HumanDecisionRequest): Promise<void> => {
1380
+ const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1381
+ await store.cancel(request, "cancelled");
1382
+ const cancellation = await store.readCancellation(request.decisionId);
1383
+ if (cancellation !== null) await settleHumanDecisionChannels(cancellation);
1384
+ lastWaitingRunId = null;
1385
+ };
1386
+
1387
+ const findOwnedWaitingHumanDecision = async (
1388
+ ctx: ExtensionContext,
1389
+ ): Promise<{ state: WorkflowRunState; request: HumanDecisionRequest } | null> => {
1390
+ const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1391
+ const sessionId = ctx.sessionManager.getSessionId();
1392
+ const owned = new Set(
1393
+ rows
1394
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1395
+ .map((row) => row.runId),
1396
+ );
1397
+ const continued = new Set(
1398
+ rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1399
+ );
1400
+ const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
1401
+ for (const bundle of bundles) {
1402
+ if (
1403
+ bundle.state.status !== "waiting" ||
1404
+ !owned.has(bundle.state.runId) ||
1405
+ continued.has(bundle.state.runId)
1406
+ ) {
1407
+ continue;
1408
+ }
1409
+ const request = humanDecisionRequest(bundle.state.finalOutput);
1410
+ if (request !== null) return { state: bundle.state, request };
1411
+ }
1412
+ return null;
1413
+ };
1414
+
1415
+ const cancelWorkflowControl = async (ctx: ExtensionContext): Promise<WorkflowControlResult> => {
1283
1416
  if (activeRun) {
1284
1417
  const workflowName = activeRun.workflowName;
1285
1418
  const runId = activeRun.runId;
@@ -1299,6 +1432,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
1299
1432
  }
1300
1433
  if (widgetSource) {
1301
1434
  const { state } = widgetSource;
1435
+ const request = humanDecisionRequest(state.finalOutput);
1436
+ if (state.status === "waiting" && request !== null) {
1437
+ await cancelHumanDecision(request);
1438
+ clearWidgetTimer();
1439
+ clearWidget(ctx);
1440
+ return {
1441
+ message: `Cancelled the pending human decision for workflow ${state.workflowName}.`,
1442
+ details: { action: "cancel", workflowName: state.workflowName, runId: state.runId },
1443
+ };
1444
+ }
1302
1445
  clearWidgetTimer();
1303
1446
  clearWidget(ctx);
1304
1447
  const detail =
@@ -1310,6 +1453,18 @@ export default function piWorkflows(pi: ExtensionAPI) {
1310
1453
  details: { action: "clear", workflowName: state.workflowName, runId: state.runId },
1311
1454
  };
1312
1455
  }
1456
+ const recovered = await findOwnedWaitingHumanDecision(ctx);
1457
+ if (recovered !== null) {
1458
+ await cancelHumanDecision(recovered.request);
1459
+ return {
1460
+ message: `Cancelled the pending human decision for workflow ${recovered.state.workflowName}.`,
1461
+ details: {
1462
+ action: "cancel",
1463
+ workflowName: recovered.state.workflowName,
1464
+ runId: recovered.state.runId,
1465
+ },
1466
+ };
1467
+ }
1313
1468
  return {
1314
1469
  message: "No workflow is running.",
1315
1470
  details: { action: "cancel", active: false },
@@ -1421,6 +1576,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1421
1576
  const resolveWaitingWorkflow = async (
1422
1577
  ctx: ExtensionContext,
1423
1578
  requestedRunId?: string,
1579
+ allowOtherSession = false,
1424
1580
  ): Promise<{ parentRunId: string; workflowRef: string }> => {
1425
1581
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1426
1582
  if (parentRunId === null) {
@@ -1448,6 +1604,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1448
1604
  }
1449
1605
  const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1450
1606
  if (
1607
+ !allowOtherSession &&
1451
1608
  queueRecord?.originSessionId !== null &&
1452
1609
  queueRecord?.originSessionId !== undefined &&
1453
1610
  queueRecord.originSessionId !== ctx.sessionManager.getSessionId()
@@ -1478,14 +1635,103 @@ export default function piWorkflows(pi: ExtensionAPI) {
1478
1635
  ctx: ExtensionContext,
1479
1636
  input: unknown,
1480
1637
  requestedRunId?: string,
1638
+ verified?: HumanDecisionChannelAnswer,
1481
1639
  ): Promise<WorkflowControlResult> => {
1482
- const waiting = await resolveWaitingWorkflow(ctx, requestedRunId);
1483
- const continued = await startRun(ctx, waiting.workflowRef, input, {
1640
+ const waiting = await resolveWaitingWorkflow(ctx, requestedRunId, verified !== undefined);
1641
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
1642
+ if (parent === null) throw new Error(`Workflow run ${waiting.parentRunId} is unreadable.`);
1643
+ const request = humanDecisionRequest(parent.state.finalOutput);
1644
+ let accepted: AcceptedHumanDecision | undefined;
1645
+ let continuationInput = input;
1646
+ let continuationRunId: string | undefined;
1647
+ if (request !== null) {
1648
+ if (
1649
+ verified !== undefined &&
1650
+ (verified.request.decisionId !== request.decisionId ||
1651
+ verified.request.requestDigest !== request.requestDigest)
1652
+ ) {
1653
+ throw new Error("Verified human answer does not match the waiting decision.");
1654
+ }
1655
+ const response = validateHumanDecisionResponse(request, verified?.response ?? input);
1656
+ const submission: HumanDecisionSubmission = {
1657
+ decisionId: request.decisionId,
1658
+ requestDigest: request.requestDigest,
1659
+ ...response,
1660
+ source: verified?.source ?? {
1661
+ channel: "pi",
1662
+ actorId: ctx.sessionManager.getSessionId(),
1663
+ eventId: createHumanDecisionAttemptId(),
1664
+ },
1665
+ idempotencyKey: verified?.idempotencyKey ?? createHumanDecisionAttemptId(),
1666
+ };
1667
+ const store = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1668
+ const acceptance = await store.accept(request, submission);
1669
+ if (acceptance.status === "conflict") {
1670
+ throw new Error("That human decision was already answered differently.");
1671
+ }
1672
+ accepted = acceptance.decision;
1673
+ continuationInput = parent.state.input;
1674
+ continuationRunId = `continuation-${request.decisionId.slice("decision-".length)}`;
1675
+ }
1676
+ if (request !== null && accepted !== undefined && verified !== undefined) {
1677
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(waiting.parentRunId);
1678
+ const currentSessionId = ctx.sessionManager.getSessionId();
1679
+ if (
1680
+ queueRecord === undefined ||
1681
+ (queueRecord.originSessionId !== null && queueRecord.originSessionId !== currentSessionId)
1682
+ ) {
1683
+ await settleHumanDecisionChannels(accepted);
1684
+ return {
1685
+ message: "Human decision accepted; its owning Pi session will continue the workflow.",
1686
+ details: {
1687
+ action: "answer",
1688
+ parentRunId: waiting.parentRunId,
1689
+ accepted: true,
1690
+ continuationPending: true,
1691
+ },
1692
+ };
1693
+ }
1694
+ }
1695
+ const decisionStore = new HumanDecisionStore(new WorkflowRunStore().outputRoot);
1696
+ if (request !== null && continuationRunId !== undefined && accepted !== undefined) {
1697
+ await decisionStore.recordContinuation(request.decisionId, {
1698
+ schema: "pi-workflows.human-decision-continuation.v1",
1699
+ decisionId: request.decisionId,
1700
+ requestDigest: request.requestDigest,
1701
+ parentRunId: waiting.parentRunId,
1702
+ runId: continuationRunId,
1703
+ createdAt: accepted.acceptedAt,
1704
+ });
1705
+ }
1706
+ if (continuationRunId !== undefined) {
1707
+ const existingContinuation = await readRunBundle(
1708
+ new WorkflowRunStore().runDirFor(continuationRunId),
1709
+ );
1710
+ if (existingContinuation !== null) {
1711
+ if (accepted !== undefined) await settleHumanDecisionChannels(accepted);
1712
+ lastWaitingRunId = null;
1713
+ return {
1714
+ message: `Human decision already continued as ${continuationRunId}.`,
1715
+ details: {
1716
+ action: "answer",
1717
+ parentRunId: waiting.parentRunId,
1718
+ runId: continuationRunId,
1719
+ adopted: true,
1720
+ },
1721
+ };
1722
+ }
1723
+ }
1724
+ const continued = await startRun(ctx, waiting.workflowRef, continuationInput, {
1484
1725
  parentRunId: waiting.parentRunId,
1726
+ ...(accepted !== undefined ? { humanDecision: accepted } : {}),
1727
+ ...(continuationRunId !== undefined ? { runId: continuationRunId } : {}),
1485
1728
  });
1486
1729
  if (continued === undefined) {
1487
1730
  throw new Error("Could not start the checkpoint continuation.");
1488
1731
  }
1732
+ if (request !== null && accepted !== undefined) {
1733
+ await settleHumanDecisionChannels(accepted);
1734
+ }
1489
1735
  lastWaitingRunId = null;
1490
1736
  return {
1491
1737
  message: `Answered checkpoint ${waiting.parentRunId}; continuation ${continued} started.`,
@@ -1497,6 +1743,158 @@ export default function piWorkflows(pi: ExtensionAPI) {
1497
1743
  };
1498
1744
  };
1499
1745
 
1746
+ async function promptHumanDecision(
1747
+ ctx: ExtensionContext,
1748
+ request: HumanDecisionRequest,
1749
+ ): Promise<void> {
1750
+ if (activePiDecisionChannels.has(request.decisionId)) return;
1751
+ const channel = new PiDecisionChannel({
1752
+ actorId: ctx.sessionManager.getSessionId(),
1753
+ ui: ctx.ui,
1754
+ store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
1755
+ onAnswer: async (answer) => {
1756
+ const result = await answerWorkflowControl(ctx, answer.response, request.runId, answer);
1757
+ notify(ctx, result.message, result.level);
1758
+ },
1759
+ });
1760
+ activePiDecisionChannels.set(request.decisionId, channel);
1761
+ try {
1762
+ await channel.deliver(humanDecisionChannelRequest(request));
1763
+ } catch (error) {
1764
+ notify(ctx, `Could not answer human decision: ${errorMessage(error)}`, "error");
1765
+ } finally {
1766
+ if (activePiDecisionChannels.get(request.decisionId) === channel) {
1767
+ activePiDecisionChannels.delete(request.decisionId);
1768
+ }
1769
+ }
1770
+ }
1771
+
1772
+ const stopDecisionChannels = async (): Promise<void> => {
1773
+ await Promise.allSettled([
1774
+ ...[...activePiDecisionChannels.values()].map(async (channel) => channel.stop()),
1775
+ ...[...telegramDecisionChannels.values()].map(async (channel) => channel.stop()),
1776
+ ]);
1777
+ activePiDecisionChannels.clear();
1778
+ telegramDecisionChannels.clear();
1779
+ decisionChannelConfig = null;
1780
+ };
1781
+
1782
+ const reloadDecisionChannels = async (ctx: ExtensionContext): Promise<void> => {
1783
+ await stopDecisionChannels();
1784
+ const loaded = await loadDecisionChannelConfig();
1785
+ if (loaded === null) return;
1786
+ decisionChannelConfig = loaded.channels;
1787
+ telegramDecisionChannels = createTelegramChannels({
1788
+ config: loaded.channels,
1789
+ credentials: loaded.credentials,
1790
+ configDir: loaded.configDir,
1791
+ store: new HumanDecisionStore(new WorkflowRunStore().outputRoot),
1792
+ onAnswer: async (answer) => {
1793
+ await answerWorkflowControl(ctx, answer.response, answer.request.runId, answer);
1794
+ },
1795
+ });
1796
+ await Promise.all(
1797
+ [...telegramDecisionChannels.values()].map(async (channel) => channel.start()),
1798
+ );
1799
+ };
1800
+
1801
+ const recoverHumanDecisions = async (
1802
+ ctx: ExtensionContext,
1803
+ deliverPending = true,
1804
+ ): Promise<void> => {
1805
+ const runStore = new WorkflowRunStore();
1806
+ const store = new HumanDecisionStore(runStore.outputRoot);
1807
+ const requests = (await store.listRequests()).sort((left, right) =>
1808
+ left.createdAt.localeCompare(right.createdAt),
1809
+ );
1810
+ for (const request of requests) {
1811
+ const parent = await readRunBundle(runStore.runDirFor(request.runId));
1812
+ if (parent === null || parent.state.status !== "waiting") continue;
1813
+ const currentRequest = humanDecisionRequest(parent.state.finalOutput);
1814
+ if (
1815
+ currentRequest === null ||
1816
+ currentRequest.decisionId !== request.decisionId ||
1817
+ currentRequest.requestDigest !== request.requestDigest
1818
+ ) {
1819
+ continue;
1820
+ }
1821
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(request.runId);
1822
+ const ownedBySession =
1823
+ queueRecord !== undefined &&
1824
+ (queueRecord.originSessionId === null ||
1825
+ queueRecord.originSessionId === ctx.sessionManager.getSessionId());
1826
+ const accepted = await store.readAccepted(request.decisionId);
1827
+ if (accepted === null) {
1828
+ let cancellation = await store.readCancellation(request.decisionId);
1829
+ if (
1830
+ cancellation === null &&
1831
+ request.expiresAt !== undefined &&
1832
+ Date.parse(request.expiresAt) <= Date.now()
1833
+ ) {
1834
+ await store.cancel(request, "expired");
1835
+ cancellation = await store.readCancellation(request.decisionId);
1836
+ }
1837
+ if (cancellation !== null) {
1838
+ await settleHumanDecisionChannels(cancellation);
1839
+ continue;
1840
+ }
1841
+ if (!deliverPending || !ownedBySession) continue;
1842
+ const channels = audienceChannels(decisionChannelConfig, request.audience);
1843
+ for (const channelId of channels) {
1844
+ const channel = telegramDecisionChannels.get(channelId);
1845
+ if (channel !== undefined) await channel.deliver(humanDecisionChannelRequest(request));
1846
+ }
1847
+ if (ctx.mode === "tui" && channels.includes("pi") && lastWaitingRunId === null) {
1848
+ lastWaitingRunId = request.runId;
1849
+ queueMicrotask(() => void promptHumanDecision(ctx, request));
1850
+ }
1851
+ continue;
1852
+ }
1853
+
1854
+ if (!ownedBySession) continue;
1855
+ const runId = `continuation-${request.decisionId.slice("decision-".length)}`;
1856
+ const continuation = (await store.readContinuation(request.decisionId)) ?? {
1857
+ schema: "pi-workflows.human-decision-continuation.v1" as const,
1858
+ decisionId: request.decisionId,
1859
+ requestDigest: request.requestDigest,
1860
+ parentRunId: request.runId,
1861
+ runId,
1862
+ createdAt: accepted.acceptedAt,
1863
+ };
1864
+ await store.recordContinuation(request.decisionId, continuation);
1865
+ const existing = await readRunBundle(runStore.runDirFor(continuation.runId));
1866
+ if (existing === null && activeRun === null) {
1867
+ if (parent.state.workflowSource === undefined) continue;
1868
+ const workflowRef =
1869
+ parent.state.workflowSource.kind === "builtin"
1870
+ ? `builtin:${parent.state.workflowSource.id}`
1871
+ : parent.state.workflowSource.path;
1872
+ await startRun(ctx, workflowRef, parent.state.input, {
1873
+ parentRunId: request.runId,
1874
+ humanDecision: accepted,
1875
+ runId: continuation.runId,
1876
+ quiet: true,
1877
+ });
1878
+ }
1879
+ await settleHumanDecisionChannels(accepted);
1880
+ if (activeRun !== null) break;
1881
+ }
1882
+ };
1883
+
1884
+ const startDecisionRecovery = (ctx: ExtensionContext): void => {
1885
+ if (decisionRecoveryTimer !== null) return;
1886
+ decisionRecoveryTimer = setInterval(() => {
1887
+ if (decisionRecoveryActive || sessionClosed) return;
1888
+ decisionRecoveryActive = true;
1889
+ void recoverHumanDecisions(ctx, false)
1890
+ .catch(() => undefined)
1891
+ .finally(() => {
1892
+ decisionRecoveryActive = false;
1893
+ });
1894
+ }, 1_000);
1895
+ decisionRecoveryTimer.unref?.();
1896
+ };
1897
+
1500
1898
  const startWorkflowControl = async (
1501
1899
  ctx: ExtensionContext,
1502
1900
  ref: string,
@@ -1610,7 +2008,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1610
2008
  return;
1611
2009
  }
1612
2010
  if (parsed.kind === "cancel") {
1613
- const result = cancelWorkflowControl(ctx);
2011
+ const result = await cancelWorkflowControl(ctx);
1614
2012
  notify(ctx, result.message, result.level);
1615
2013
  return;
1616
2014
  }
@@ -1658,6 +2056,69 @@ export default function piWorkflows(pi: ExtensionAPI) {
1658
2056
  },
1659
2057
  });
1660
2058
 
2059
+ pi.registerCommand("workflow-channel", {
2060
+ description: "Configure or reload private human decision channels",
2061
+ handler: async (args, ctx) => {
2062
+ const action = args.trim() || "status";
2063
+ try {
2064
+ if (action === "reload") {
2065
+ await reloadDecisionChannels(ctx);
2066
+ notify(ctx, "Human decision channels reloaded.");
2067
+ return;
2068
+ }
2069
+ if (action === "status") {
2070
+ notify(
2071
+ ctx,
2072
+ decisionChannelConfig === null
2073
+ ? "Human decisions use the Pi channel only."
2074
+ : `Human decision channels are configured for ${Object.keys(decisionChannelConfig.audiences).length} audience(s).`,
2075
+ );
2076
+ return;
2077
+ }
2078
+ if (action !== "setup") {
2079
+ throw new Error("Use /workflow-channel status, setup, or reload.");
2080
+ }
2081
+ if (!ctx.hasUI || ctx.mode !== "tui") {
2082
+ throw new Error("Channel setup requires interactive Pi TUI mode.");
2083
+ }
2084
+ const tokenFile = await ctx.ui.input(
2085
+ "Absolute path to the mode-0600 Telegram token file",
2086
+ "",
2087
+ );
2088
+ if (tokenFile === undefined) return;
2089
+ const audience = await ctx.ui.input("Logical audience", "operator");
2090
+ if (audience === undefined) return;
2091
+ const profile = await ctx.ui.input("Private Telegram profile name", "default");
2092
+ if (profile === undefined) return;
2093
+ const credential = await ctx.ui.input("Private credential reference name", "telegram");
2094
+ if (credential === undefined) return;
2095
+ const users = await ctx.ui.input("Allowed numeric Telegram user IDs, comma separated", "");
2096
+ if (users === undefined) return;
2097
+ const chats = await ctx.ui.input("Allowed numeric Telegram chat IDs, comma separated", "");
2098
+ if (chats === undefined) return;
2099
+ await verifyTelegramTokenFile(tokenFile.trim());
2100
+ await writeDecisionChannelProfile({
2101
+ audience: audience.trim(),
2102
+ profile: profile.trim(),
2103
+ credential: credential.trim(),
2104
+ tokenFile: tokenFile.trim(),
2105
+ allowedUserIds: users
2106
+ .split(",")
2107
+ .map((value) => value.trim())
2108
+ .filter(Boolean),
2109
+ allowedChatIds: chats
2110
+ .split(",")
2111
+ .map((value) => value.trim())
2112
+ .filter(Boolean),
2113
+ });
2114
+ await reloadDecisionChannels(ctx);
2115
+ notify(ctx, "The private human decision channel profile was verified and installed.");
2116
+ } catch (error) {
2117
+ notify(ctx, `Human decision channel setup failed: ${errorMessage(error)}`, "error");
2118
+ }
2119
+ },
2120
+ });
2121
+
1661
2122
  pi.registerCommand("controller", {
1662
2123
  description: "Manage durable controllers: list, get, apply, reconcile, delete, start, or stop",
1663
2124
  getArgumentCompletions: async (prefix: string) => {
@@ -1737,7 +2198,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
1737
2198
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
1738
2199
  ].join(" "),
1739
2200
  parameters: WorkflowToolParameters,
1740
- async execute(toolCallId, params: WorkflowToolInput, _signal, _onUpdate, ctx) {
2201
+ async execute(toolCallId, rawParams, _signal, _onUpdate, ctx) {
2202
+ const params = parseWorkflowToolInput(rawParams);
1741
2203
  let control: WorkflowControlResult;
1742
2204
  switch (params.action) {
1743
2205
  case "list":
@@ -1756,10 +2218,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
1756
2218
  control = resumeWorkflowControl(ctx);
1757
2219
  break;
1758
2220
  case "cancel":
1759
- control = cancelWorkflowControl(ctx);
2221
+ control = await cancelWorkflowControl(ctx);
1760
2222
  break;
1761
2223
  case "answer": {
1762
2224
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
2225
+ const parent = await readRunBundle(new WorkflowRunStore().runDirFor(waiting.parentRunId));
2226
+ if (parent !== null && humanDecisionRequest(parent.state.finalOutput) !== null) {
2227
+ throw new Error(
2228
+ "This checkpoint requires a verified human answer from Pi UI or a configured decision channel.",
2229
+ );
2230
+ }
1763
2231
  control = await queueToolLaunch(ctx, waiting.workflowRef, params.input, {
1764
2232
  parentRunId: waiting.parentRunId,
1765
2233
  });
@@ -1857,12 +2325,28 @@ export default function piWorkflows(pi: ExtensionAPI) {
1857
2325
  } catch (error) {
1858
2326
  notify(ctx, `Could not migrate legacy workflow sources: ${errorMessage(error)}`, "warning");
1859
2327
  }
2328
+ try {
2329
+ await reloadDecisionChannels(ctx);
2330
+ } catch {
2331
+ await stopDecisionChannels();
2332
+ notify(
2333
+ ctx,
2334
+ "Could not start human decision channels because the private channel configuration is invalid or unavailable.",
2335
+ "warning",
2336
+ );
2337
+ }
1860
2338
  try {
1861
2339
  syncArmed = true;
1862
2340
  startRunSync(ctx);
1863
2341
  } catch {
1864
2342
  // Session sync is best-effort; runs themselves never depend on it.
1865
2343
  }
2344
+ try {
2345
+ await recoverHumanDecisions(ctx);
2346
+ startDecisionRecovery(ctx);
2347
+ } catch (error) {
2348
+ notify(ctx, `Could not recover human decisions: ${errorMessage(error)}`, "warning");
2349
+ }
1866
2350
  try {
1867
2351
  await resumeParkedRun(ctx);
1868
2352
  } catch (error) {
@@ -2008,7 +2492,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
2008
2492
  clearInterval(runSyncTimer);
2009
2493
  runSyncTimer = null;
2010
2494
  }
2495
+ if (decisionRecoveryTimer !== null) {
2496
+ clearInterval(decisionRecoveryTimer);
2497
+ decisionRecoveryTimer = null;
2498
+ }
2499
+ decisionRecoveryActive = false;
2011
2500
  syncArmed = false;
2501
+ await stopDecisionChannels();
2012
2502
  await controllerHost?.close().catch(() => undefined);
2013
2503
  controllerHost = undefined;
2014
2504
  controllerContext = null;