@llblab/pi-kit 0.9.0 → 0.10.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 (33) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -0
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -0
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +115 -8
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  9. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +7 -0
  10. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +10 -0
  11. package/node_modules/@llblab/pi-state-flow/README.md +4 -2
  12. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -3
  13. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -2
  14. package/node_modules/@llblab/pi-state-flow/index.ts +22 -1
  15. package/node_modules/@llblab/pi-state-flow/lib/context.ts +1 -1
  16. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +248 -144
  17. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +267 -0
  18. package/node_modules/@llblab/pi-state-flow/lib/terminal.ts +1 -1
  19. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  20. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  21. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -1
  22. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +8 -0
  23. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  24. package/node_modules/@llblab/pi-telegram/docs/architecture.md +1 -1
  25. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +4 -4
  26. package/node_modules/@llblab/pi-telegram/docs/outbound.md +1 -1
  27. package/node_modules/@llblab/pi-telegram/docs/public-api.md +2 -2
  28. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +1 -1
  29. package/node_modules/@llblab/pi-telegram/lib/config.ts +6 -4
  30. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +2 -1
  31. package/node_modules/@llblab/pi-telegram/lib/preview.ts +20 -0
  32. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  33. package/package.json +4 -4
@@ -7,6 +7,7 @@ import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol }
7
7
  import { createPassiveContinuation, currentRunTrajectory, passiveContinuationMessages, runtimeContextMessage, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
8
8
  import { ArtifactReadTracker } from "./acquisition.ts";
9
9
  import { loadStateFlowConfig } from "./config.ts";
10
+ import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
10
11
  import { isAbsolute, relative, resolve, sep } from "node:path";
11
12
  import { SkillReadTracker } from "./skills.ts";
12
13
  import { emptySnapshot, migrationFailure, persistableSnapshot, type Snapshot } from "./snapshot.ts";
@@ -44,11 +45,12 @@ export interface StateFlowExtensionOptions {
44
45
  repositoryRoot?: string;
45
46
  knowledgeRoot?: string;
46
47
  onRuntime?: (accessor: { read(offset?: number, scope?: StateScope): MaterializedState }) => void;
48
+ telegram?: { load?: StateFlowTelegramLoader };
47
49
  }
48
50
 
49
51
  export const PATCH_STATE_TOOL_NAME = "patch_state";
50
52
  export const READ_STATE_TOOL_NAME = "read_state";
51
- export const MAX_RESOLUTION_ATTEMPTS = 3;
53
+ export const MAX_FALLBACK_ATTEMPTS: number = 2;
52
54
  const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
53
55
 
54
56
  /** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
@@ -65,9 +67,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
65
67
  let branchHasSnapshot = false;
66
68
  let branchStartsWithoutRuntime = false;
67
69
  let terminalEligible = false;
68
- let terminalDraftIntercepted = false;
69
- let resolutionAttempts = 0;
70
- let resolutionFailureReported = false;
70
+ let resolutionPending = false;
71
+ let fallbackAttempts = 0;
72
+ let fallbackFailureReported = false;
71
73
  let responseAwaitingReconciliation = false;
72
74
  let passiveContinuation: PassiveContinuation | undefined;
73
75
  let bootstrapContinuation: PassiveContinuation | undefined;
@@ -85,6 +87,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
85
87
  const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
86
88
  let artifactInvalidations: ArtifactInvalidationRequest[] = [];
87
89
  let loggingWarningReported = false;
90
+ let telegramStartPending = false;
88
91
 
89
92
  function sessionAddress(ctx: ExtensionContext): SessionAddress {
90
93
  return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
@@ -124,9 +127,9 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
124
127
 
125
128
  function clearRunTransient(): void {
126
129
  terminalEligible = false;
127
- terminalDraftIntercepted = false;
128
- resolutionAttempts = 0;
129
- resolutionFailureReported = false;
130
+ resolutionPending = false;
131
+ fallbackAttempts = 0;
132
+ fallbackFailureReported = false;
130
133
  responseAwaitingReconciliation = false;
131
134
  runAnchorTimestamp = undefined;
132
135
  skillReads.clear();
@@ -273,10 +276,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
273
276
  }, runtime!.causalBasis(), { finalizeRun });
274
277
  if (!committed) return false;
275
278
  installScopeStates();
276
- artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
277
- artifactReads.setCandidates(artifactInvalidations);
278
- skillReads.clear();
279
- artifactReads.clear();
279
+ // A preserved primary response commits mid-run; pending acquisition obligations must
280
+ // still block final eligibility until the fallback turns resolve or expire.
281
+ if (!(finalizeRun && resolutionPending)) {
282
+ artifactInvalidations = artifactInvalidations.filter(({ path }) => !acquiredArtifactPaths.has(path));
283
+ artifactReads.setCandidates(artifactInvalidations);
284
+ skillReads.clear();
285
+ artifactReads.clear();
286
+ }
280
287
  persist();
281
288
  return true;
282
289
  }
@@ -451,6 +458,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
451
458
  artifactInvalidations = [];
452
459
  artifactReads.setCandidates([]);
453
460
  artifactRefreshPending = false;
461
+ telegramStartPending = false;
454
462
  activeContext = ctx;
455
463
  const session = sessionAddress(ctx);
456
464
  runtime = new TemporalRuntime(ctx.cwd, session, repositoryRoot);
@@ -579,15 +587,77 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
579
587
  }
580
588
  }
581
589
 
582
- function continueForResolution(): void {
583
- terminalDraftIntercepted = true;
590
+ function continueFallbackResolution(lastWarning: boolean): void {
591
+ resolutionPending = true;
584
592
  pi.sendMessage({
585
593
  customType: VALIDATION_MESSAGE_TYPE,
586
- content: "Before completing this turn, make the State Flow iteration terminal-eligible. Call patch_state with any durable scope changes and final:true, or call patch_state with {\"final\":true} when no semantic update is needed. Then provide the final answer normally.",
594
+ content: lastWarning
595
+ ? "This is the last State Flow fallback turn. The iteration's answer is preserved and will not change. Apply the final:true patch now: call patch_state with any remaining durable scope changes and final:true, or with {\"final\":true} when nothing remains. Do not restate the answer."
596
+ : "The iteration's answer is preserved as the final response; later turns cannot replace it. Apply the final:true patch: call patch_state with any durable scope changes from this iteration (including required artifact or Skill compilation) and final:true, or with {\"final\":true} when nothing remains to persist. Do not restate the answer.",
587
597
  display: false,
588
598
  }, { deliverAs: "steer", triggerTurn: true });
589
599
  }
590
600
 
601
+ /** Preserve the primary answer as this iteration's response and steer bounded fallback turns whose only purpose is the final:true patch. */
602
+ function beginFallbackResolution(ctx: ExtensionContext, message: { content?: unknown }, reason: string): void {
603
+ responseAwaitingReconciliation = true;
604
+ fallbackAttempts = 0;
605
+ fallbackFailureReported = false;
606
+ recordDiagnostic(`Preserved the terminal draft as the iteration response; fallback resolution started: ${reason}`, "terminal-pending", ctx, {
607
+ content: message.content,
608
+ resolutionAttempt: 0,
609
+ terminalEligible,
610
+ });
611
+ continueFallbackResolution(MAX_FALLBACK_ATTEMPTS === 1);
612
+ }
613
+
614
+ /** Fallback turns never become the response; they exist only to supply the final:true patch. */
615
+ function resolveFallbackTurn(ctx: ExtensionContext, message: { content?: unknown }): any {
616
+ let resolved = terminalEligible;
617
+ if (resolved) {
618
+ try {
619
+ validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
620
+ } catch (error) {
621
+ resolved = false;
622
+ recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, {
623
+ content: message.content,
624
+ resolutionAttempt: fallbackAttempts,
625
+ terminalEligible,
626
+ });
627
+ }
628
+ }
629
+ if (resolved) {
630
+ resolutionPending = false;
631
+ recordDiagnostic(`Fallback resolution obtained final:true after ${fallbackAttempts} fallback turn${fallbackAttempts === 1 ? "" : "s"}; the preserved answer stands`, "finalization", ctx, {
632
+ content: message.content,
633
+ resolutionAttempt: fallbackAttempts,
634
+ terminalEligible,
635
+ });
636
+ return { message: { ...message, role: "assistant" as const, content: [] } };
637
+ }
638
+ fallbackAttempts = Math.min(MAX_FALLBACK_ATTEMPTS, fallbackAttempts + 1);
639
+ if (fallbackAttempts < MAX_FALLBACK_ATTEMPTS) {
640
+ recordDiagnostic(`Fallback turn ended without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS})`, "terminal-pending", ctx, {
641
+ content: message.content,
642
+ resolutionAttempt: fallbackAttempts,
643
+ terminalEligible,
644
+ });
645
+ continueFallbackResolution(fallbackAttempts + 1 >= MAX_FALLBACK_ATTEMPTS);
646
+ return { message: { ...message, role: "assistant" as const, content: [] } };
647
+ }
648
+ resolutionPending = false;
649
+ recordDiagnostic(`Fallback resolution exhausted without final:true (${fallbackAttempts}/${MAX_FALLBACK_ATTEMPTS}); the preserved response and current state remain`, "finalization", ctx, {
650
+ content: message.content,
651
+ resolutionAttempt: fallbackAttempts,
652
+ terminalEligible,
653
+ });
654
+ if (!fallbackFailureReported) {
655
+ fallbackFailureReported = true;
656
+ ctx.ui.notify(`State Flow kept the preserved answer; no final:true patch arrived after ${MAX_FALLBACK_ATTEMPTS} fallback turns, so the iteration closed with its current state.`, "warning");
657
+ }
658
+ return { message: { ...message, role: "assistant" as const, content: [] } };
659
+ }
660
+
591
661
  pi.registerTool({
592
662
  name: READ_STATE_TOOL_NAME,
593
663
  label: "Read State",
@@ -654,7 +724,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
654
724
  if (params.final !== true) throw new Error('patch_state requires at least one scope patch or {"final":true}');
655
725
  validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
656
726
  terminalEligible = true;
657
- terminalDraftIntercepted = false;
658
727
  return { content: [{ type: "text", text: "\nState iteration is terminal-eligible." }], details: { final: true } };
659
728
  }
660
729
  const stage = stageAtomicScopePatches(scopeStates, patches, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
@@ -664,7 +733,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
664
733
  commitStage(stage, ctx, false);
665
734
  if (params.final === true) {
666
735
  terminalEligible = true;
667
- terminalDraftIntercepted = false;
668
736
  }
669
737
  updateUi(ctx);
670
738
  const publication = pendingPublication === undefined ? "" : "; durable publication pending";
@@ -676,7 +744,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
676
744
  input: attempted,
677
745
  tool: PATCH_STATE_TOOL_NAME,
678
746
  toolCallId,
679
- resolutionAttempt: resolutionAttempts,
747
+ resolutionAttempt: fallbackAttempts,
680
748
  terminalEligible,
681
749
  });
682
750
  throw separatedFailure(error);
@@ -684,74 +752,79 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
684
752
  },
685
753
  });
686
754
 
687
- pi.registerCommand("state-flow-start", {
688
- description: "Start State Flow mode",
689
- handler: async (_args, ctx) => {
690
- if (!activeContext) restoreActiveBranch(ctx);
691
- const previousSnapshot = structuredClone(snapshot);
692
- const previousPassiveContinuation = passiveContinuation;
693
- const previousBootstrapContinuation = bootstrapContinuation;
694
- const previousArtifactRefreshPending = artifactRefreshPending;
695
- const previousArtifactInvalidations = structuredClone(artifactInvalidations);
696
- try {
697
- if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
698
- throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
699
- }
700
- const branch = ctx.sessionManager.getBranch();
701
- activeContext = ctx;
702
- runtime ??= createRuntime(ctx);
703
- if (branchStartsWithoutRuntime) runtime.prepare();
704
- const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
705
- && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
706
- if (!runtime.view && snapshot.meta.durableBase) {
707
- snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
708
- setPendingPublication(snapshot.meta.pendingPublication);
709
- branchHasSnapshot = true;
710
- }
711
- const existingBranch = branchHasSnapshot;
712
- snapshot = existingBranch
713
- ? resumeEpisode(snapshot, bootstrap)
714
- : startEpisode(bootstrap);
715
- if (snapshot.meta.remotePublication === undefined) {
716
- snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
717
- resolveRemotePublicationPolicy(existingBranch ? undefined : config.remotePublication, { legacyRuntime: existingBranch }),
718
- );
719
- }
755
+ function startStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
756
+ if (!activeContext) restoreActiveBranch(ctx);
757
+ telegramStartPending = false;
758
+ const previousSnapshot = structuredClone(snapshot);
759
+ const previousPassiveContinuation = passiveContinuation;
760
+ const previousBootstrapContinuation = bootstrapContinuation;
761
+ const previousArtifactRefreshPending = artifactRefreshPending;
762
+ const previousArtifactInvalidations = structuredClone(artifactInvalidations);
763
+ try {
764
+ if (!runtime?.view && !snapshot.meta.durableBase && !branchStartsWithoutRuntime) {
765
+ throw new Error("Selected branch revision is unavailable; restore its original Git history before starting State Flow");
766
+ }
767
+ const branch = ctx.sessionManager.getBranch();
768
+ activeContext = ctx;
769
+ runtime ??= createRuntime(ctx);
770
+ if (branchStartsWithoutRuntime) runtime.prepare();
771
+ const bootstrap = (!branchHasSnapshot || !snapshot.config.enabled)
772
+ && (hasPriorConversation(branch) || previousPassiveContinuation !== undefined);
773
+ if (!runtime.view && snapshot.meta.durableBase) {
774
+ snapshot = runtime.restore(snapshot.meta.durableBase, snapshot);
775
+ setPendingPublication(snapshot.meta.pendingPublication);
720
776
  branchHasSnapshot = true;
721
- const publication = runtime.view
722
- ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
723
- : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
724
- recordPolicyPublication(publication, ctx);
725
- installScopeStates();
726
- delete snapshot.legacySession;
727
- clearRunTransient();
728
- passiveContinuation = undefined;
729
- bootstrapContinuation = snapshot.meta.bootstrap
730
- ? previousPassiveContinuation ?? previousBootstrapContinuation
731
- : undefined;
732
- deferArtifactRefresh();
733
- syncStateFlowTools();
734
- persist();
735
- updateUi(ctx);
736
- ctx.ui.notify(
737
- snapshot.meta.bootstrap
738
- ? "State Flow enabled. The next complete agent run will migrate active context into state."
739
- : "State Flow enabled. The next prompt starts a stateful agent run.",
740
- "info",
741
- );
742
- } catch (error) {
743
- snapshot = previousSnapshot;
744
- passiveContinuation = previousPassiveContinuation;
745
- bootstrapContinuation = previousBootstrapContinuation;
746
- artifactRefreshPending = previousArtifactRefreshPending;
747
- artifactInvalidations = previousArtifactInvalidations;
748
- artifactReads.setCandidates(artifactInvalidations);
749
- syncStateFlowTools();
750
- ctx.ui.notify(
751
- `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`,
752
- "error",
777
+ }
778
+ const existingBranch = branchHasSnapshot;
779
+ snapshot = existingBranch
780
+ ? resumeEpisode(snapshot, bootstrap)
781
+ : startEpisode(bootstrap);
782
+ if (snapshot.meta.remotePublication === undefined) {
783
+ snapshot.meta.remotePublication = serializeRemotePublicationPolicyDocument(
784
+ resolveRemotePublicationPolicy(existingBranch ? undefined : config.remotePublication, { legacyRuntime: existingBranch }),
753
785
  );
754
786
  }
787
+ branchHasSnapshot = true;
788
+ const publication = runtime.view
789
+ ? runtime.promote(snapshot) ?? runtime.publish(snapshot)
790
+ : runtime.initialize(snapshot, true, undefined, branchStartsWithoutRuntime);
791
+ recordPolicyPublication(publication, ctx);
792
+ installScopeStates();
793
+ delete snapshot.legacySession;
794
+ clearRunTransient();
795
+ passiveContinuation = undefined;
796
+ bootstrapContinuation = snapshot.meta.bootstrap
797
+ ? previousPassiveContinuation ?? previousBootstrapContinuation
798
+ : undefined;
799
+ deferArtifactRefresh();
800
+ syncStateFlowTools();
801
+ persist();
802
+ updateUi(ctx);
803
+ ctx.ui.notify(
804
+ snapshot.meta.bootstrap
805
+ ? "State Flow enabled. The next complete agent run will migrate active context into state."
806
+ : "State Flow enabled. The next prompt starts a stateful agent run.",
807
+ "info",
808
+ );
809
+ return { ok: true, message: "State Flow enabled" };
810
+ } catch (error) {
811
+ snapshot = previousSnapshot;
812
+ passiveContinuation = previousPassiveContinuation;
813
+ bootstrapContinuation = previousBootstrapContinuation;
814
+ artifactRefreshPending = previousArtifactRefreshPending;
815
+ artifactInvalidations = previousArtifactInvalidations;
816
+ artifactReads.setCandidates(artifactInvalidations);
817
+ syncStateFlowTools();
818
+ const message = `State Flow could not initialize CWD state: ${error instanceof Error ? error.message : String(error)}`;
819
+ ctx.ui.notify(message, "error");
820
+ return { ok: false, message };
821
+ }
822
+ }
823
+
824
+ pi.registerCommand("state-flow-start", {
825
+ description: "Start State Flow mode",
826
+ handler: async (_args, ctx) => {
827
+ startStateFlow(ctx);
755
828
  },
756
829
  });
757
830
 
@@ -762,56 +835,94 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
762
835
  },
763
836
  });
764
837
 
838
+ function stopStateFlow(ctx: ExtensionContext): StateFlowTelegramControlResult {
839
+ if (!activeContext) restoreActiveBranch(ctx);
840
+ telegramStartPending = false;
841
+ let selected = runtime;
842
+ let current = snapshot;
843
+ if (!selected?.view && snapshot.meta.durableBase) {
844
+ selected = createRuntime(ctx);
845
+ current = selected.restore(snapshot.meta.durableBase, snapshot);
846
+ }
847
+ const stoppedAt = Date.now();
848
+ const exitStates = selected?.view ? selected.states() : undefined;
849
+ const exitHandoff = current.config.enabled && exitStates
850
+ ? createPassiveContinuation(
851
+ projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
852
+ stoppedAt,
853
+ )
854
+ : undefined;
855
+ const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
856
+ const stopped = stopEpisode(current);
857
+ const publication = selected?.view ? selected.publish(stopped) : undefined;
858
+ if (selected !== runtime) {
859
+ runtime = selected;
860
+ installScopeStates();
861
+ }
862
+ snapshot = stopped;
863
+ recordPolicyPublication(publication, ctx);
864
+ branchHasSnapshot = true;
865
+ clearRunTransient();
866
+ passiveContinuation = retainedHandoff;
867
+ bootstrapContinuation = undefined;
868
+ artifactInvalidations = [];
869
+ artifactReads.setCandidates([]);
870
+ artifactRefreshPending = false;
871
+ if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
872
+ syncStateFlowTools();
873
+ persist();
874
+ updateUi(ctx);
875
+ return { ok: true, message: "State Flow disabled" };
876
+ }
877
+
765
878
  pi.registerCommand("state-flow-stop", {
766
879
  description: "Stop State Flow on the current session branch",
767
880
  handler: async (_args, ctx) => {
768
- if (!activeContext) restoreActiveBranch(ctx);
769
- let selected = runtime;
770
- let current = snapshot;
771
- if (!selected?.view && snapshot.meta.durableBase) {
772
- selected = createRuntime(ctx);
773
- current = selected.restore(snapshot.meta.durableBase, snapshot);
774
- }
775
- const stoppedAt = Date.now();
776
- const exitStates = selected?.view ? selected.states() : undefined;
777
- const exitHandoff = current.config.enabled && exitStates
778
- ? createPassiveContinuation(
779
- projectStateForModel(overlayStates(exitStates.global, exitStates.cwd, exitStates.session)),
780
- stoppedAt,
781
- )
782
- : undefined;
783
- const retainedHandoff = exitHandoff ?? (!current.config.enabled ? passiveContinuation : undefined);
784
- const stopped = stopEpisode(current);
785
- const publication = selected?.view ? selected.publish(stopped) : undefined;
786
- if (selected !== runtime) {
787
- runtime = selected;
788
- installScopeStates();
789
- }
790
- snapshot = stopped;
791
- recordPolicyPublication(publication, ctx);
792
- branchHasSnapshot = true;
793
- clearRunTransient();
794
- passiveContinuation = retainedHandoff;
795
- bootstrapContinuation = undefined;
796
- artifactInvalidations = [];
797
- artifactReads.setCandidates([]);
798
- artifactRefreshPending = false;
799
- if (exitHandoff) pi.appendEntry(PASSIVE_STOP_ENTRY_TYPE, { at: stoppedAt });
800
- syncStateFlowTools();
801
- persist();
802
- updateUi(ctx);
881
+ stopStateFlow(ctx);
803
882
  },
804
883
  });
805
884
 
885
+ const telegram = createStateFlowTelegramAdapter({
886
+ ...(options.telegram?.load === undefined ? {} : { load: options.telegram.load }),
887
+ port: {
888
+ snapshot: () => ({
889
+ enabled: snapshot.config.enabled,
890
+ step: snapshot.meta.step,
891
+ bootstrap: snapshot.meta.bootstrap === true,
892
+ startPending: telegramStartPending,
893
+ }),
894
+ canStartNow: () => activeContext === undefined || activeContext.isIdle(),
895
+ start: () => {
896
+ if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
897
+ return startStateFlow(activeContext);
898
+ },
899
+ stop: () => {
900
+ if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
901
+ try {
902
+ return stopStateFlow(activeContext);
903
+ } catch (error) {
904
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
905
+ }
906
+ },
907
+ deferStart: () => {
908
+ telegramStartPending = true;
909
+ },
910
+ cancelStart: () => {
911
+ telegramStartPending = false;
912
+ },
913
+ },
914
+ });
915
+ void telegram.ensure();
916
+
806
917
  pi.on("before_agent_start", (event, ctx) => {
807
918
  if (!snapshot.config.enabled) return;
808
919
  skillReads.clear();
809
920
  artifactReads.clear();
810
921
  if (artifactRefreshPending) refreshArtifactInvalidations(ctx);
811
922
  terminalEligible = false;
812
- terminalDraftIntercepted = false;
813
- resolutionAttempts = 0;
814
- resolutionFailureReported = false;
923
+ resolutionPending = false;
924
+ fallbackAttempts = 0;
925
+ fallbackFailureReported = false;
815
926
  responseAwaitingReconciliation = false;
816
927
  const rotatesRun = snapshot.meta.specification !== undefined;
817
928
  if (rotatesRun && rehydrationPhase !== "new-bootstrap" && rehydrationPhase !== "resume-bootstrap") rehydrationPhase = "step";
@@ -841,7 +952,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
841
952
  ? passiveContinuationMessages(event.messages as AgentMessage[], bootstrapContinuation)
842
953
  : event.messages as AgentMessage[];
843
954
  const messages = withoutPrivateValidation(sourceMessages);
844
- return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted), ...messages] };
955
+ return { messages: [runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending), ...messages] };
845
956
  }
846
957
  const trajectory = currentRunTrajectory(
847
958
  event.messages as AgentMessage[],
@@ -851,7 +962,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
851
962
  runAnchorTimestamp = trajectory.anchorTimestamp;
852
963
  return {
853
964
  messages: [
854
- runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, terminalDraftIntercepted),
965
+ runtimeContextMessage(snapshot, effectiveState, recentTransitions, invalidations, activeRehydrationPhase, resolutionPending),
855
966
  ...trajectory.messages,
856
967
  ],
857
968
  };
@@ -905,31 +1016,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
905
1016
  recordDiagnostic(`Assistant response ended with ${message.stopReason}`, "finalization", ctx, { content: message.content, terminalEligible });
906
1017
  return;
907
1018
  }
1019
+ if (resolutionPending) return resolveFallbackTurn(ctx, message);
908
1020
  if (!terminalEligible) {
909
- responseAwaitingReconciliation = false;
910
- terminalDraftIntercepted = true;
911
- resolutionAttempts = Math.min(MAX_RESOLUTION_ATTEMPTS, resolutionAttempts + 1);
912
- recordDiagnostic(`Terminal draft intercepted before State Flow eligibility (attempt ${resolutionAttempts}/${MAX_RESOLUTION_ATTEMPTS})`, "terminal-pending", ctx, {
913
- content: message.content,
914
- resolutionAttempt: resolutionAttempts,
915
- terminalEligible,
916
- });
917
- if (resolutionAttempts < MAX_RESOLUTION_ATTEMPTS) {
918
- continueForResolution();
919
- } else if (!resolutionFailureReported) {
920
- resolutionFailureReported = true;
921
- ctx.ui.notify(`State Flow could not obtain final:true after ${MAX_RESOLUTION_ATTEMPTS} terminal attempts; committed state was preserved and no draft was accepted.`, "error");
922
- }
923
- return { message: { ...message, role: "assistant" as const, content: [] } };
1021
+ beginFallbackResolution(ctx, message, "the terminal draft ended without State Flow eligibility");
1022
+ return;
924
1023
  }
925
1024
  try {
926
1025
  validateFinalEligibility(scopeStates, skillReads.successful.values(), runtime!.causalBasis(), artifactReads.successful.values());
927
1026
  } catch (error) {
928
- responseAwaitingReconciliation = false;
929
- terminalDraftIntercepted = true;
930
- recordDiagnostic(error instanceof Error ? error.message : String(error), "terminal-pending", ctx, { content: message.content, terminalEligible });
931
- continueForResolution();
932
- return { message: { ...message, role: "assistant" as const, content: [] } };
1027
+ beginFallbackResolution(ctx, message, error instanceof Error ? error.message : String(error));
1028
+ return;
933
1029
  }
934
1030
  responseAwaitingReconciliation = true;
935
1031
  });
@@ -952,12 +1048,15 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
952
1048
  ctx.ui.notify(`State Flow could not reconcile the final response: ${error instanceof Error ? error.message : String(error)}`, "error");
953
1049
  } finally {
954
1050
  responseAwaitingReconciliation = false;
955
- terminalDraftIntercepted = false;
956
1051
  }
957
1052
  updateUi(ctx);
958
1053
  });
959
1054
 
960
- pi.on("agent_settled", (_event, _ctx) => {
1055
+ pi.on("agent_settled", (_event, ctx) => {
1056
+ if (telegramStartPending && !snapshot.config.enabled) {
1057
+ telegramStartPending = false;
1058
+ startStateFlow(ctx);
1059
+ }
961
1060
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
962
1061
  });
963
1062
 
@@ -967,8 +1066,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
967
1066
  retryPendingPush(ctx);
968
1067
  if (snapshot.meta.remotePublication?.mode === "turn-end") launchPublicationWorker();
969
1068
  updateUi(ctx);
1069
+ void telegram.ensure();
970
1070
  });
971
1071
  pi.on("session_tree", (_event, ctx) => {
972
1072
  restoreActiveBranch(ctx);
973
1073
  });
1074
+ pi.on("session_shutdown", () => {
1075
+ telegramStartPending = false;
1076
+ telegram.dispose();
1077
+ });
974
1078
  }