@osolmaz/pi-workflows 0.11.2 → 0.12.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 (123) hide show
  1. package/README.md +11 -7
  2. package/dist/builtins/autoimplement-command-batches.d.ts +52 -0
  3. package/dist/builtins/autoimplement-command-batches.js +258 -0
  4. package/dist/builtins/autoimplement-command-batches.js.map +1 -0
  5. package/dist/builtins/autoimplement.workflow.d.ts +29 -3
  6. package/dist/builtins/autoimplement.workflow.js +876 -231
  7. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  8. package/dist/builtins/catalog.js +3 -1
  9. package/dist/builtins/catalog.js.map +1 -1
  10. package/dist/builtins/index.d.ts +2 -0
  11. package/dist/builtins/index.js +1 -0
  12. package/dist/builtins/index.js.map +1 -1
  13. package/dist/builtins/sanity-check-session.d.ts +17 -0
  14. package/dist/builtins/sanity-check-session.js +168 -0
  15. package/dist/builtins/sanity-check-session.js.map +1 -0
  16. package/dist/builtins/sanity-check.workflow.d.ts +83 -0
  17. package/dist/builtins/sanity-check.workflow.js +398 -0
  18. package/dist/builtins/sanity-check.workflow.js.map +1 -0
  19. package/dist/controllers/sqlite.d.ts +90 -2
  20. package/dist/controllers/sqlite.js +380 -5
  21. package/dist/controllers/sqlite.js.map +1 -1
  22. package/dist/extension/decision-channels.js +7 -7
  23. package/dist/extension/deferred-turn-coordinator.d.ts +32 -0
  24. package/dist/extension/deferred-turn-coordinator.js +143 -0
  25. package/dist/extension/deferred-turn-coordinator.js.map +1 -0
  26. package/dist/extension/deferred-turn.d.ts +44 -0
  27. package/dist/extension/deferred-turn.js +110 -0
  28. package/dist/extension/deferred-turn.js.map +1 -0
  29. package/dist/extension/index.js +286 -47
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/step-message.d.ts +1 -0
  32. package/dist/extension/step-message.js.map +1 -1
  33. package/dist/herdr/setup.js +4 -4
  34. package/dist/workflows/command-batch.d.ts +38 -0
  35. package/dist/workflows/command-batch.js +176 -0
  36. package/dist/workflows/command-batch.js.map +1 -0
  37. package/dist/workflows/engine.js +8 -4
  38. package/dist/workflows/engine.js.map +1 -1
  39. package/dist/workflows/index.d.ts +1 -0
  40. package/dist/workflows/index.js +1 -0
  41. package/dist/workflows/index.js.map +1 -1
  42. package/dist/workflows/schema.js +2 -1
  43. package/dist/workflows/schema.js.map +1 -1
  44. package/dist/workflows/shell.d.ts +4 -0
  45. package/dist/workflows/shell.js +6 -0
  46. package/dist/workflows/shell.js.map +1 -1
  47. package/dist/workflows/store.js +3 -1
  48. package/dist/workflows/store.js.map +1 -1
  49. package/dist/workflows/types.d.ts +4 -3
  50. package/docs/2026-08-18-herdr-piw-plan.md +1 -1
  51. package/docs/2026-08-20-durable-workflow-launch-plan.md +19 -23
  52. package/docs/CONTROLLERS.md +2 -2
  53. package/docs/DEFERRED_TURNS.md +298 -0
  54. package/docs/DESIGN_PHILOSOPHY.md +1 -1
  55. package/docs/HUMAN_DECISIONS.md +7 -7
  56. package/docs/HUMAN_DECISION_PRESENTATIONS.md +3 -3
  57. package/docs/MONITOR.md +2 -2
  58. package/docs/WORKFLOW_COMPOSITION.md +2 -2
  59. package/docs/WORKFLOW_STEP_MESSAGES.md +5 -5
  60. package/docs/WORKFLOW_UPDATES.md +28 -5
  61. package/docs/plans/2026-08-04-controller-runtime-plan.md +1 -1
  62. package/docs/plans/2026-08-05-always-on-workflows-plan.md +1 -1
  63. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +9 -9
  64. package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +1 -1
  65. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +1 -1
  66. package/docs/plans/2026-08-16-workflow-updates-plan.md +3 -3
  67. package/docs/plans/2026-08-17-bundled-skills-plan.md +6 -6
  68. package/docs/plans/2026-08-19-human-decision-gates-plan.md +5 -5
  69. package/docs/plans/2026-08-19-human-decision-presentations-plan.md +1 -1
  70. package/docs/plans/2026-08-19-provider-compatible-workflow-tool-schema-plan.md +1 -1
  71. package/docs/plans/2026-08-19-workflow-composition-plan.md +6 -6
  72. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +3 -3
  73. package/docs/plans/2026-08-20-bounded-command-batches-plan.md +206 -0
  74. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +9 -9
  75. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +242 -0
  76. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +324 -0
  77. package/docs/plans/2026-08-21-sanity-check-plan.md +175 -0
  78. package/docs/run-bundles.md +3 -1
  79. package/docs/workflows.md +62 -19
  80. package/examples/workflows/command-batch.workflow.ts +50 -0
  81. package/examples/workflows/sanity-check.workflow.ts +1 -0
  82. package/herdr-plugin.toml +3 -3
  83. package/package.json +1 -1
  84. package/schemas/decision-presentation-v1.schema.json +1 -1
  85. package/schemas/human-decision-accepted-v1.schema.json +1 -1
  86. package/schemas/human-decision-accepted-v2.schema.json +1 -1
  87. package/schemas/human-decision-answer-attempt-v1.schema.json +1 -1
  88. package/schemas/human-decision-cancellation-v1.schema.json +1 -1
  89. package/schemas/human-decision-continuation-v1.schema.json +1 -1
  90. package/schemas/human-decision-delivery-v1.schema.json +1 -1
  91. package/schemas/human-decision-delivery-v2.schema.json +1 -1
  92. package/schemas/human-decision-receipt-v1.schema.json +1 -1
  93. package/schemas/human-decision-receipt-v2.schema.json +1 -1
  94. package/schemas/human-decision-request-v1.schema.json +1 -1
  95. package/schemas/human-decision-request-v2.schema.json +1 -1
  96. package/schemas/human-decision-resolution-v1.schema.json +1 -1
  97. package/schemas/human-decision-resolution-v2.schema.json +1 -1
  98. package/schemas/human-decision-settlement-v1.schema.json +1 -1
  99. package/skills/autodoc/SKILL.md +33 -2
  100. package/skills/autoimplement/SKILL.md +66 -12
  101. package/skills/autoplan/SKILL.md +26 -2
  102. package/skills/monitor/SKILL.md +35 -21
  103. package/skills/pi-workflows/SKILL.md +19 -5
  104. package/src/builtins/autoimplement-command-batches.ts +364 -0
  105. package/src/builtins/autoimplement.workflow.ts +1065 -261
  106. package/src/builtins/catalog.ts +3 -1
  107. package/src/builtins/index.ts +13 -0
  108. package/src/builtins/sanity-check-session.ts +205 -0
  109. package/src/builtins/sanity-check.workflow.ts +624 -0
  110. package/src/controllers/sqlite.ts +580 -7
  111. package/src/extension/decision-channels.ts +7 -7
  112. package/src/extension/deferred-turn-coordinator.ts +171 -0
  113. package/src/extension/deferred-turn.ts +166 -0
  114. package/src/extension/index.ts +359 -49
  115. package/src/extension/step-message.ts +1 -0
  116. package/src/herdr/setup.ts +4 -4
  117. package/src/workflows/command-batch.ts +254 -0
  118. package/src/workflows/engine.ts +8 -5
  119. package/src/workflows/index.ts +15 -0
  120. package/src/workflows/schema.ts +2 -1
  121. package/src/workflows/shell.ts +10 -0
  122. package/src/workflows/store.ts +3 -1
  123. package/src/workflows/types.ts +4 -3
@@ -8,6 +8,10 @@ import {
8
8
  SqliteControllerStore,
9
9
  type WorkflowRunQueueRecord,
10
10
  } from "../controllers/index.js";
11
+ import type {
12
+ WorkflowTurnIntentCause,
13
+ WorkflowTurnIntentResolution,
14
+ } from "../controllers/sqlite.js";
11
15
  import type { JsonObject } from "../controllers/types.js";
12
16
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
13
17
  import { compositionMetadata } from "../workflows/composition.js";
@@ -63,6 +67,19 @@ import {
63
67
  type HumanDecisionChannelAnswer,
64
68
  type SettledHumanDecision,
65
69
  } from "./decision-channels.js";
70
+ import {
71
+ DeferredTurnCoordinator,
72
+ type BranchIntentResolution,
73
+ } from "./deferred-turn-coordinator.js";
74
+ import {
75
+ buildDeferredTurnContent,
76
+ createDeferredTurnDescriptor,
77
+ DEFERRED_TURN_MESSAGE_TYPE,
78
+ deferredTurnMessageDetails,
79
+ deferredTurnMessageId,
80
+ deferredTurnSourceEventId,
81
+ type DeferredTurnDescriptor,
82
+ } from "./deferred-turn.js";
66
83
  import { ConversationStepExecutor } from "./executor.js";
67
84
  import {
68
85
  HerdrWorkflowViewer,
@@ -108,8 +125,10 @@ const RUN_CLAIM_LEASE_MS = 30_000;
108
125
  const RUN_CLAIM_RENEW_MS = 10_000;
109
126
  const RUN_SYNC_POLL_MS = 3_000;
110
127
  const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
128
+ const TURN_INTENT_DELIVERY_LEASE_MS = 30_000;
111
129
  const WIDGET_KEY = "pi-workflows";
112
130
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
131
+ const PRESENTATION_MESSAGE_SCHEMA = "pi-workflows.presentation-message.v1";
113
132
  const FINAL_WIDGET_TTL_MS = 60_000;
114
133
  const WIDGET_SCROLL_STEP = 3;
115
134
  const MAX_PRESENTATION_RESULT_CHARS = 50_000;
@@ -138,6 +157,12 @@ type PresentationPromptBuilder = Exclude<
138
157
  string | undefined
139
158
  >;
140
159
 
160
+ type AbortProvenance = {
161
+ cause: WorkflowTurnIntentCause;
162
+ descriptor?: DeferredTurnDescriptor;
163
+ storageError?: string;
164
+ };
165
+
141
166
  type ActiveRun = {
142
167
  runId: string;
143
168
  workflowName: string;
@@ -153,6 +178,9 @@ type ActiveRun = {
153
178
  onFinish?: (result: WorkflowSchedulerResult) => void;
154
179
  completion?: Promise<void>;
155
180
  interruptionRequested?: boolean;
181
+ pendingAbortCause?: WorkflowTurnIntentCause | undefined;
182
+ abortProvenance?: AbortProvenance | undefined;
183
+ suppressTurnIntent?: boolean | undefined;
156
184
  claimToken?: string | undefined;
157
185
  renewTimer?: ReturnType<typeof setInterval> | undefined;
158
186
  /** True for runs this session resumed from the queue. */
@@ -400,6 +428,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
400
428
  let activationRecovery: ((ctx: ExtensionContext) => void) | undefined;
401
429
  let decisionRecoveryTimer: ReturnType<typeof setInterval> | null = null;
402
430
  let decisionRecoveryActive = false;
431
+ let turnCoordinator: DeferredTurnCoordinator;
403
432
 
404
433
  const recordRunEvent = (event: {
405
434
  runId: string;
@@ -453,9 +482,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
453
482
  kind: notification.kind,
454
483
  },
455
484
  },
456
- notification.kind === "launch_failure"
457
- ? { triggerTurn: true, deliverAs: "followUp" }
458
- : { triggerTurn: false },
485
+ { triggerTurn: false },
459
486
  );
460
487
  alreadyDelivered.add(notification.notificationId);
461
488
  }
@@ -465,6 +492,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
465
492
  claimToken,
466
493
  });
467
494
  }
495
+ const idle = ctx.isIdle() && systemTurnAbort === null && !sessionClosed && !runHeld();
496
+ turnCoordinator.flushNatural(idle);
497
+ turnCoordinator.deliverFallbacks(
498
+ {
499
+ targetSessionId: sessionId,
500
+ send: (intent) => {
501
+ pi.sendMessage(
502
+ {
503
+ customType: DEFERRED_TURN_MESSAGE_TYPE,
504
+ content: buildDeferredTurnContent(intent),
505
+ display: true,
506
+ details: deferredTurnMessageDetails(intent),
507
+ },
508
+ { triggerTurn: true, deliverAs: "followUp" },
509
+ );
510
+ },
511
+ },
512
+ idle,
513
+ );
468
514
  } catch {
469
515
  // Delivery retries on the next poll. It never affects workflow execution.
470
516
  }
@@ -480,7 +526,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
480
526
  runSyncTimer.unref?.();
481
527
  };
482
528
  let activeRun: ActiveRun | null = null;
483
- let systemTurnAbort: AgentStepContract | null = null;
529
+ let systemTurnAbort: { contract: AgentStepContract; intentId?: string } | null = null;
484
530
  let suppressWorkflowAssistantTail = false;
485
531
  let lastExpiredAttempt: { contract: AgentStepContract; reason: string } | null = null;
486
532
  // The interactive run currently parked at a checkpoint, if any.
@@ -501,6 +547,30 @@ export default function piWorkflows(pi: ExtensionAPI) {
501
547
  let controllerHost: PiControllerHost | undefined;
502
548
  let controllerContext: ExtensionContext | null = null;
503
549
 
550
+ const branchIntentResolution = (intentId: string): BranchIntentResolution | null => {
551
+ if (controllerContext === null) return null;
552
+ for (const entry of controllerContext.sessionManager.getBranch()) {
553
+ if (entry.type !== "custom_message") continue;
554
+ const details = entry.details;
555
+ if (details === null || typeof details !== "object" || Array.isArray(details)) continue;
556
+ if ((details as { turnIntentId?: unknown }).turnIntentId !== intentId) continue;
557
+ let resolution: WorkflowTurnIntentResolution | null = null;
558
+ if (entry.customType === WORKFLOW_AGENT_STEP_MESSAGE_TYPE) resolution = "workflowPrompt";
559
+ if (entry.customType === PRESENTATION_MESSAGE_TYPE) resolution = "presentation";
560
+ if (entry.customType === DEFERRED_TURN_MESSAGE_TYPE) resolution = "fallback";
561
+ if (resolution !== null) {
562
+ return { resolution, messageId: deferredTurnMessageId(intentId, resolution) };
563
+ }
564
+ }
565
+ return null;
566
+ };
567
+
568
+ turnCoordinator = new DeferredTurnCoordinator({
569
+ store: () => runQueueStore,
570
+ branchResolution: branchIntentResolution,
571
+ leaseMs: TURN_INTENT_DELIVERY_LEASE_MS,
572
+ });
573
+
504
574
  // UI updates are best-effort: a captured ctx becomes stale after session
505
575
  // replacement or shutdown, and pi throws on any access (even `ctx.hasUI`).
506
576
  // A workflow finishing right as the session goes away must not crash pi.
@@ -542,6 +612,145 @@ export default function piWorkflows(pi: ExtensionAPI) {
542
612
  const runHeld = (): boolean =>
543
613
  activeRun !== null && (activeRun.engine.pauseRequested || activeRun.executor.held);
544
614
 
615
+ const originSessionId = (ctx: ExtensionContext, runId: string): string =>
616
+ ensureRunQueueStore(ctx.cwd).getWorkflowRun(runId)?.originSessionId ??
617
+ ctx.sessionManager.getSessionId();
618
+
619
+ const storeTurnDescriptor = (
620
+ ctx: ExtensionContext,
621
+ descriptor: DeferredTurnDescriptor,
622
+ eligible: boolean,
623
+ ): void => {
624
+ ensureRunQueueStore(ctx.cwd).ensureWorkflowTurnIntent({ ...descriptor, eligible });
625
+ };
626
+
627
+ const ensureAbortTurnIntent = (
628
+ ctx: ExtensionContext,
629
+ run: ActiveRun,
630
+ cause: WorkflowTurnIntentCause,
631
+ contract: AgentStepContract,
632
+ reason: unknown,
633
+ ): DeferredTurnDescriptor => {
634
+ if (run.abortProvenance?.descriptor !== undefined) {
635
+ return run.abortProvenance.descriptor;
636
+ }
637
+ const descriptor = createDeferredTurnDescriptor({
638
+ runId: run.runId,
639
+ workflowName: run.workflowName,
640
+ targetSessionId: originSessionId(ctx, run.runId),
641
+ cause,
642
+ sourceEventId: deferredTurnSourceEventId({
643
+ runId: run.runId,
644
+ cause,
645
+ nodeId: contract.nodeId,
646
+ attemptId: contract.attemptId,
647
+ source: "agent-step-abort",
648
+ }),
649
+ observedState: cause === "claimLost" ? "handedOff" : "interrupted",
650
+ nodeId: contract.nodeId,
651
+ attemptId: contract.attemptId,
652
+ reason: errorMessage(reason),
653
+ handoff: cause === "claimLost",
654
+ });
655
+ run.abortProvenance = { cause, descriptor };
656
+ try {
657
+ storeTurnDescriptor(ctx, descriptor, false);
658
+ } catch (error) {
659
+ run.abortProvenance.storageError = errorMessage(error);
660
+ }
661
+ return descriptor;
662
+ };
663
+
664
+ const makeRunTurnIntentEligible = (
665
+ ctx: ExtensionContext,
666
+ run: ActiveRun,
667
+ observedState: string,
668
+ reason?: string,
669
+ ): void => {
670
+ if (
671
+ sessionClosed ||
672
+ run.suppressTurnIntent === true ||
673
+ run.abortProvenance?.cause === "claimLost"
674
+ ) {
675
+ return;
676
+ }
677
+ const targetSessionId = originSessionId(ctx, run.runId);
678
+ const pending = ensureRunQueueStore(ctx.cwd).findPendingWorkflowTurnIntent({
679
+ runId: run.runId,
680
+ targetSessionId,
681
+ });
682
+ if (
683
+ run.childKey !== undefined &&
684
+ run.abortProvenance === undefined &&
685
+ pending?.cause !== "claimLost"
686
+ ) {
687
+ return;
688
+ }
689
+ const cause =
690
+ pending?.cause ??
691
+ run.abortProvenance?.cause ??
692
+ (observedState === "timed_out" ? "timedOut" : "failed");
693
+ if (
694
+ observedState === "cancelled" &&
695
+ run.abortProvenance === undefined &&
696
+ pending === undefined
697
+ ) {
698
+ return;
699
+ }
700
+ const previous = run.abortProvenance?.descriptor;
701
+ const sourceEventId =
702
+ pending?.sourceEventId ??
703
+ previous?.sourceEventId ??
704
+ deferredTurnSourceEventId({
705
+ runId: run.runId,
706
+ cause,
707
+ nodeId: "$terminal",
708
+ source: "terminal",
709
+ });
710
+ const descriptor = createDeferredTurnDescriptor({
711
+ runId: run.runId,
712
+ workflowName: pending?.workflowRef ?? run.workflowName,
713
+ targetSessionId,
714
+ cause,
715
+ sourceEventId,
716
+ observedState,
717
+ nodeId: pending?.nodeId ?? previous?.nodeId ?? "$terminal",
718
+ attemptId: pending?.attemptId ?? previous?.attemptId ?? null,
719
+ reason: reason ?? null,
720
+ handoff: false,
721
+ });
722
+ run.abortProvenance = {
723
+ cause,
724
+ descriptor,
725
+ ...(run.abortProvenance?.storageError === undefined
726
+ ? {}
727
+ : { storageError: run.abortProvenance.storageError }),
728
+ };
729
+ try {
730
+ storeTurnDescriptor(ctx, descriptor, false);
731
+ ensureRunQueueStore(ctx.cwd).makeWorkflowTurnIntentEligible({
732
+ intentId: descriptor.intentId,
733
+ fallbackFacts: descriptor.fallbackFacts,
734
+ });
735
+ delete run.abortProvenance.storageError;
736
+ syncArmed = true;
737
+ } catch (error) {
738
+ const message = errorMessage(error);
739
+ run.abortProvenance.storageError = message;
740
+ recordRunEvent({
741
+ runId: run.runId,
742
+ workflowRef: run.workflowName,
743
+ type: "turn_intent_failed",
744
+ payload: { error: message },
745
+ });
746
+ notify(
747
+ ctx,
748
+ `Workflow ${run.workflowName} ended, but Pi Workflows could not preserve its successor turn: ${message}`,
749
+ "warning",
750
+ );
751
+ }
752
+ };
753
+
545
754
  const footerStatus = (state: WorkflowRunState): string => {
546
755
  const label = runHeld() || state.paused ? "paused" : state.status;
547
756
  const node = state.currentNode ?? state.waitingOn;
@@ -742,24 +951,41 @@ export default function piWorkflows(pi: ExtensionAPI) {
742
951
  typeof run.presentationPrompt === "function"
743
952
  ? await resolvePresentationPrompt(run.presentationPrompt, state, abort.signal)
744
953
  : run.presentationPrompt;
745
- if (
746
- sessionClosed ||
747
- run.generation !== runGeneration ||
748
- abort.signal.aborted ||
749
- instructions === undefined ||
750
- instructions.trim().length === 0
751
- ) {
954
+ if (sessionClosed || run.generation !== runGeneration || abort.signal.aborted) {
752
955
  return;
753
956
  }
754
- presentationPending = run.generation;
755
- suppressWorkflowAssistantTail = false;
756
- pi.sendMessage(
957
+ if (instructions === undefined || instructions.trim().length === 0) {
958
+ if (run.abortProvenance !== undefined) {
959
+ makeRunTurnIntentEligible(ctx, run, state.status, "No result presentation was available");
960
+ }
961
+ return;
962
+ }
963
+ turnCoordinator.sendNatural(
757
964
  {
758
- customType: PRESENTATION_MESSAGE_TYPE,
759
- content: buildPresentationMessage(instructions, state),
760
- display: false,
965
+ runId: run.runId,
966
+ targetSessionId: originSessionId(ctx, run.runId),
967
+ resolution: "presentation",
968
+ send: (turnIntentId) => {
969
+ presentationPending = run.generation;
970
+ suppressWorkflowAssistantTail = false;
971
+ pi.sendMessage(
972
+ {
973
+ customType: PRESENTATION_MESSAGE_TYPE,
974
+ content: buildPresentationMessage(instructions, state),
975
+ display: false,
976
+ details: {
977
+ schema: PRESENTATION_MESSAGE_SCHEMA,
978
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
979
+ },
980
+ },
981
+ {
982
+ deliverAs: turnIntentId === undefined ? "steer" : "followUp",
983
+ triggerTurn: true,
984
+ },
985
+ );
986
+ },
761
987
  },
762
- { deliverAs: "steer", triggerTurn: true },
988
+ ctx.isIdle() && systemTurnAbort === null,
763
989
  );
764
990
  } catch (error) {
765
991
  if (presentationPending === run.generation) {
@@ -777,6 +1003,9 @@ export default function piWorkflows(pi: ExtensionAPI) {
777
1003
  ? `timed out after ${PRESENTATION_TIMEOUT_MS}ms`
778
1004
  : errorMessage(error);
779
1005
  notify(ctx, `Could not present workflow result: ${message}`, "warning");
1006
+ if (run.abortProvenance !== undefined) {
1007
+ makeRunTurnIntentEligible(ctx, run, state.status, message);
1008
+ }
780
1009
  } finally {
781
1010
  clearTimeout(timer);
782
1011
  if (presentationAbort === abort) {
@@ -829,6 +1058,22 @@ export default function piWorkflows(pi: ExtensionAPI) {
829
1058
  clearWidget(ctx);
830
1059
  return;
831
1060
  }
1061
+ if (run.abortProvenance?.cause === "claimLost") {
1062
+ await run.recorder?.stop().catch(() => undefined);
1063
+ releaseClaim(run, "lost");
1064
+ recordRunEvent({
1065
+ runId: run.runId,
1066
+ workflowRef: run.workflowName,
1067
+ type: "claim_lost",
1068
+ });
1069
+ stopWidgetTicker();
1070
+ clearWidget(ctx);
1071
+ notify(
1072
+ ctx,
1073
+ `Workflow ${run.workflowName} continues under another runner (run ${run.runId}).`,
1074
+ );
1075
+ return;
1076
+ }
832
1077
  releaseClaim(run, "done");
833
1078
  recordRunEvent({
834
1079
  runId: run.runId,
@@ -880,6 +1125,15 @@ export default function piWorkflows(pi: ExtensionAPI) {
880
1125
  } catch (error) {
881
1126
  notify(ctx, `Could not record child workflow completion: ${errorMessage(error)}`, "warning");
882
1127
  }
1128
+ if (state.status === "failed" || state.status === "timed_out" || state.status === "cancelled") {
1129
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1130
+ } else if (
1131
+ run.abortProvenance !== undefined &&
1132
+ run.presentationPrompt === undefined &&
1133
+ (state.status === "completed" || state.status === "waiting")
1134
+ ) {
1135
+ makeRunTurnIntentEligible(ctx, run, state.status, state.error);
1136
+ }
883
1137
  void presentRun(ctx, run, state);
884
1138
  if (pendingDecision !== null && state.status === "waiting") {
885
1139
  const channels = audienceChannels(decisionChannelConfig, pendingDecision.audience);
@@ -1011,23 +1265,34 @@ export default function piWorkflows(pi: ExtensionAPI) {
1011
1265
 
1012
1266
  const executor = new ConversationStepExecutor({
1013
1267
  sendPrompt: ({ prompt, contract, presentation, kind, streaming }) => {
1014
- const details: WorkflowAgentStepMessageDetails = {
1015
- schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
1016
- kind,
1017
- contract,
1018
- ...(presentation !== undefined ? { presentation } : {}),
1019
- };
1020
- pi.sendMessage(
1021
- {
1022
- customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
1023
- content: prompt,
1024
- display: true,
1025
- details,
1026
- },
1268
+ turnCoordinator.sendNatural(
1027
1269
  {
1028
- triggerTurn: true,
1029
- deliverAs: streaming ? "steer" : "followUp",
1270
+ runId,
1271
+ targetSessionId: originSessionId(ctx, runId),
1272
+ resolution: "workflowPrompt",
1273
+ send: (turnIntentId) => {
1274
+ const details: WorkflowAgentStepMessageDetails = {
1275
+ schema: WORKFLOW_AGENT_STEP_MESSAGE_SCHEMA,
1276
+ kind,
1277
+ contract,
1278
+ ...(presentation !== undefined ? { presentation } : {}),
1279
+ ...(turnIntentId === undefined ? {} : { turnIntentId }),
1280
+ };
1281
+ pi.sendMessage(
1282
+ {
1283
+ customType: WORKFLOW_AGENT_STEP_MESSAGE_TYPE,
1284
+ content: prompt,
1285
+ display: true,
1286
+ details,
1287
+ },
1288
+ {
1289
+ triggerTurn: true,
1290
+ deliverAs: streaming ? "steer" : "followUp",
1291
+ },
1292
+ );
1293
+ },
1030
1294
  },
1295
+ ctx.isIdle() && systemTurnAbort === null,
1031
1296
  );
1032
1297
  },
1033
1298
  onAbort: (contract, reason) => {
@@ -1036,7 +1301,16 @@ export default function piWorkflows(pi: ExtensionAPI) {
1036
1301
  reason: reason instanceof TimeoutError ? "timed out" : `ended: ${errorMessage(reason)}`,
1037
1302
  };
1038
1303
  if (!executor.held && !ctx.isIdle()) {
1039
- systemTurnAbort = contract;
1304
+ const cause = reason instanceof TimeoutError ? "timedOut" : run.pendingAbortCause;
1305
+ const descriptor =
1306
+ cause === undefined
1307
+ ? undefined
1308
+ : ensureAbortTurnIntent(ctx, run, cause, contract, reason);
1309
+ run.pendingAbortCause = undefined;
1310
+ systemTurnAbort = {
1311
+ contract,
1312
+ ...(descriptor === undefined ? {} : { intentId: descriptor.intentId }),
1313
+ };
1040
1314
  ctx.abort();
1041
1315
  }
1042
1316
  },
@@ -1137,6 +1411,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1137
1411
  if (
1138
1412
  !store.renewWorkflowRunClaim({ runId, claimToken: token, leaseMs: RUN_CLAIM_LEASE_MS })
1139
1413
  ) {
1414
+ run.pendingAbortCause = "claimLost";
1140
1415
  run.engine.cancel();
1141
1416
  }
1142
1417
  } catch {
@@ -1239,6 +1514,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1239
1514
  }
1240
1515
  if (terminalStatus !== undefined) {
1241
1516
  recordRunEvent({ runId, workflowRef: workflow.name, type: terminalStatus, payload: {} });
1517
+ if (
1518
+ terminalStatus === "failed" ||
1519
+ terminalStatus === "timed_out" ||
1520
+ terminalStatus === "cancelled"
1521
+ ) {
1522
+ makeRunTurnIntentEligible(ctx, run, terminalStatus, message);
1523
+ }
1242
1524
  notify(ctx, `Workflow ${workflow.name} ${terminalStatus} (run ${runId}).`);
1243
1525
  return;
1244
1526
  }
@@ -1248,6 +1530,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1248
1530
  type: "failed",
1249
1531
  payload: { error: message },
1250
1532
  });
1533
+ makeRunTurnIntentEligible(ctx, run, "failed", message);
1251
1534
  try {
1252
1535
  run.onFinish?.({ state: "failed", runId, error: message });
1253
1536
  } catch {
@@ -1478,11 +1761,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
1478
1761
 
1479
1762
  const cancelWorkflowControl = async (
1480
1763
  ctx: ExtensionContext,
1764
+ origin: "agent" | "user",
1481
1765
  requestedRunId?: string,
1482
1766
  ): Promise<WorkflowControlResult> => {
1483
1767
  if (activeRun && (requestedRunId === undefined || requestedRunId === activeRun.runId)) {
1484
1768
  const workflowName = activeRun.workflowName;
1485
1769
  const runId = activeRun.runId;
1770
+ activeRun.pendingAbortCause = origin === "agent" ? "agentCancelled" : undefined;
1771
+ activeRun.suppressTurnIntent = origin === "user";
1486
1772
  activeRun.engine.cancel();
1487
1773
  return {
1488
1774
  message: `Cancelling workflow ${workflowName}…`,
@@ -1581,6 +1867,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1581
1867
  },
1582
1868
  };
1583
1869
  }
1870
+ activeRun.suppressTurnIntent = true;
1584
1871
  activeRun.engine.pause();
1585
1872
  renderWidget(ctx);
1586
1873
  return {
@@ -1614,6 +1901,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1614
1901
  level: "warning",
1615
1902
  };
1616
1903
  }
1904
+ activeRun.suppressTurnIntent = false;
1617
1905
  activeRun.engine.resume();
1618
1906
  activeRun.executor.release();
1619
1907
  renderWidget(ctx);
@@ -2167,19 +2455,31 @@ export default function piWorkflows(pi: ExtensionAPI) {
2167
2455
  payload: { errorCode: safe.code, error: safe.message },
2168
2456
  });
2169
2457
  const content = `Workflow ${claimed.workflowName} failed to start (run ${claimed.runId}): ${safe.message}. Inspect the error and call workflow start again only after you correct the cause.`;
2170
- try {
2171
- queue.enqueueWorkflowNotification({
2458
+ const cause = "launchFailed" as const;
2459
+ const descriptor = createDeferredTurnDescriptor({
2460
+ runId: claimed.runId,
2461
+ workflowName: claimed.workflowName,
2462
+ targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2463
+ cause,
2464
+ sourceEventId: deferredTurnSourceEventId({
2172
2465
  runId: claimed.runId,
2466
+ cause,
2173
2467
  nodeId: "$launch",
2174
- attemptId: claimed.runId,
2175
- notificationIndex: 1,
2176
- targetSessionId: claimed.originSessionId ?? ctx.sessionManager.getSessionId(),
2177
- kind: "launch_failure",
2178
- content,
2179
- notificationId: `launch-failure:${claimed.runId}`,
2180
- });
2181
- } catch {
2182
- // A deterministic notification id makes duplicate insertion harmless.
2468
+ source: "queued-launch",
2469
+ }),
2470
+ observedState: "failedToStart",
2471
+ nodeId: "$launch",
2472
+ reason: safe.message,
2473
+ });
2474
+ try {
2475
+ storeTurnDescriptor(ctx, descriptor, true);
2476
+ syncArmed = true;
2477
+ } catch (intentError) {
2478
+ notify(
2479
+ ctx,
2480
+ `Workflow ${claimed.workflowName} failed to start, but Pi Workflows could not preserve its successor turn: ${errorMessage(intentError)}`,
2481
+ "warning",
2482
+ );
2183
2483
  }
2184
2484
  notify(ctx, content, "error");
2185
2485
  runSyncPass(ctx);
@@ -2244,7 +2544,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2244
2544
  return;
2245
2545
  }
2246
2546
  if (parsed.kind === "cancel") {
2247
- const result = await cancelWorkflowControl(ctx);
2547
+ const result = await cancelWorkflowControl(ctx, "user");
2248
2548
  notify(ctx, result.message, result.level);
2249
2549
  return;
2250
2550
  }
@@ -2412,6 +2712,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2412
2712
  case "stop":
2413
2713
  if (activeRun?.childKey !== undefined) {
2414
2714
  activeRun.interruptionRequested = true;
2715
+ activeRun.pendingAbortCause = "controllerInterrupted";
2415
2716
  activeRun.engine.cancel();
2416
2717
  }
2417
2718
  await host.stop();
@@ -2428,7 +2729,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2428
2729
  name: "workflow",
2429
2730
  label: "Workflow",
2430
2731
  description: [
2431
- "List, start, inspect, pause, resume, cancel, answer, update, or complete Pi Workflows runs.",
2732
+ "List, start, inspect, pause, resume, cancel, answer, update, or complete pi-workflows runs.",
2432
2733
  "When the user asks to monitor, watch, poll, or check something repeatedly, start the built-in monitor workflow with input keys task, everyMinutes, stopWhen, and optional maxChecks.",
2433
2734
  "Use update or submit only when a workflow step contract asks for it, and pass the exact step and attempt ids.",
2434
2735
  "Do not start repeated work without the user's request, and keep monitoring observation-only unless the user authorizes mutations.",
@@ -2454,7 +2755,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2454
2755
  control = resumeWorkflowControl(ctx);
2455
2756
  break;
2456
2757
  case "cancel":
2457
- control = await cancelWorkflowControl(ctx, params.runId);
2758
+ control = await cancelWorkflowControl(ctx, "agent", params.runId);
2458
2759
  break;
2459
2760
  case "answer": {
2460
2761
  const waiting = await resolveWaitingWorkflow(ctx, params.runId);
@@ -2636,6 +2937,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2636
2937
  if (!aborted || runHeld()) {
2637
2938
  return;
2638
2939
  }
2940
+ run.suppressTurnIntent = true;
2639
2941
  run.engine.pause();
2640
2942
  run.executor.hold();
2641
2943
  renderWidget(ctx);
@@ -2686,6 +2988,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
2686
2988
  });
2687
2989
 
2688
2990
  pi.on("agent_settled", async (_event, ctx) => {
2991
+ systemTurnAbort = null;
2689
2992
  if (activeRun === null) {
2690
2993
  try {
2691
2994
  const prepared = ensureRunQueueStore(ctx.cwd).findSessionReservation(
@@ -2704,15 +3007,20 @@ export default function piWorkflows(pi: ExtensionAPI) {
2704
3007
  return;
2705
3008
  }
2706
3009
  }
3010
+ const flushedNatural = turnCoordinator.flushNatural(
3011
+ ctx.isIdle() && !sessionClosed && !runHeld(),
3012
+ );
2707
3013
  const run = activeRun;
2708
3014
  if (!run) {
2709
3015
  presentationPending = null;
3016
+ runSyncPass(ctx);
2710
3017
  return;
2711
3018
  }
2712
3019
  await run.recorder?.synchronize(ctx).catch(() => undefined);
2713
3020
  run.recorder?.settleAttempt();
2714
3021
  run.executor.setStreaming(false);
2715
- run.executor.handleAgentSettled();
3022
+ if (flushedNatural === 0) run.executor.handleAgentSettled();
3023
+ runSyncPass(ctx);
2716
3024
  });
2717
3025
 
2718
3026
  pi.on("session_shutdown", async () => {
@@ -2720,8 +3028,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
2720
3028
  herdrProbeGeneration += 1;
2721
3029
  systemTurnAbort = null;
2722
3030
  suppressWorkflowAssistantTail = false;
3031
+ turnCoordinator.clearDeferred();
2723
3032
  supersedePresentation();
2724
3033
  const run = activeRun;
3034
+ if (run !== null) run.suppressTurnIntent = true;
2725
3035
  if (run !== null && run.claimToken !== undefined) {
2726
3036
  // Queued interactive runs park: no terminal event, no recorded partial
2727
3037
  // attempt, and the claim releases so another runner can resume.
@@ -12,6 +12,7 @@ export type WorkflowAgentStepMessageDetails = {
12
12
  kind: PromptDeliveryKind;
13
13
  contract: AgentStepContract;
14
14
  presentation?: AgentStepPresentation;
15
+ turnIntentId?: string;
15
16
  };
16
17
 
17
18
  type WorkflowAgentStepMessage = {
@@ -115,20 +115,20 @@ function preflightPackage(packageRoot: string): PluginPackage {
115
115
  try {
116
116
  root = fs.realpathSync(requestedRoot);
117
117
  } catch (error) {
118
- throw new Error(`Pi Workflows package root is missing: ${requestedRoot}`, { cause: error });
118
+ throw new Error(`pi-workflows package root is missing: ${requestedRoot}`, { cause: error });
119
119
  }
120
120
  if (!fs.statSync(root).isDirectory()) {
121
- throw new Error(`Pi Workflows package root is not a directory: ${requestedRoot}`);
121
+ throw new Error(`pi-workflows package root is not a directory: ${requestedRoot}`);
122
122
  }
123
123
  const packagePath = checkedRegularFile(root, "package.json");
124
124
  const manifestPath = checkedRegularFile(root, MANIFEST_NAME);
125
125
  const packageJson = parseJsonObject(fs.readFileSync(packagePath, "utf8"), "package.json");
126
126
  if (packageJson["name"] !== PACKAGE_NAME) {
127
- throw new Error(`Unexpected Pi Workflows package name in ${packagePath}.`);
127
+ throw new Error(`Unexpected pi-workflows package name in ${packagePath}.`);
128
128
  }
129
129
  const packageVersion = packageJson["version"];
130
130
  if (typeof packageVersion !== "string" || packageVersion.length === 0) {
131
- throw new Error(`Pi Workflows package version is missing in ${packagePath}.`);
131
+ throw new Error(`pi-workflows package version is missing in ${packagePath}.`);
132
132
  }
133
133
 
134
134
  const manifest = fs.readFileSync(manifestPath, "utf8");