@llblab/pi-telegram 0.54.4 → 0.55.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 (80) hide show
  1. package/AGENTS.md +4 -2
  2. package/BACKLOG.md +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +5 -3
  5. package/dist/lib/activity.d.ts +5 -1
  6. package/dist/lib/activity.js +31 -2
  7. package/dist/lib/bindings.d.ts +3 -1
  8. package/dist/lib/bindings.js +8 -2
  9. package/dist/lib/bus-api.d.ts +2 -2
  10. package/dist/lib/bus-api.js +7 -2
  11. package/dist/lib/bus-follower.d.ts +49 -3
  12. package/dist/lib/bus-follower.js +313 -146
  13. package/dist/lib/bus-leader.d.ts +3 -0
  14. package/dist/lib/bus-leader.js +99 -18
  15. package/dist/lib/bus.d.ts +5 -3
  16. package/dist/lib/bus.js +13 -10
  17. package/dist/lib/delivery.d.ts +4 -0
  18. package/dist/lib/delivery.js +48 -15
  19. package/dist/lib/extension.js +48 -6
  20. package/dist/lib/lifecycle.d.ts +68 -0
  21. package/dist/lib/lifecycle.js +178 -58
  22. package/dist/lib/machine.d.ts +23 -0
  23. package/dist/lib/machine.js +37 -0
  24. package/dist/lib/media.d.ts +114 -28
  25. package/dist/lib/media.js +292 -119
  26. package/dist/lib/model.d.ts +54 -15
  27. package/dist/lib/model.js +98 -53
  28. package/dist/lib/polling.js +2 -0
  29. package/dist/lib/preview.d.ts +128 -113
  30. package/dist/lib/preview.js +448 -366
  31. package/dist/lib/queue.d.ts +2 -2
  32. package/dist/lib/queue.js +3 -3
  33. package/dist/lib/routing.d.ts +6 -1
  34. package/dist/lib/routing.js +334 -210
  35. package/dist/lib/runtime.d.ts +77 -11
  36. package/dist/lib/runtime.js +174 -92
  37. package/dist/lib/sections.js +0 -1
  38. package/dist/lib/telegram-api.d.ts +11 -9
  39. package/dist/lib/telegram-api.js +53 -4
  40. package/dist/lib/text-groups.d.ts +0 -26
  41. package/dist/lib/text-groups.js +51 -98
  42. package/dist/lib/thread-display.js +14 -6
  43. package/dist/lib/threads.d.ts +18 -5
  44. package/dist/lib/threads.js +107 -30
  45. package/dist/lib/turns.d.ts +2 -0
  46. package/dist/lib/turns.js +6 -1
  47. package/dist/lib/updates.d.ts +11 -4
  48. package/dist/lib/updates.js +54 -19
  49. package/dist/package.json +1 -1
  50. package/docs/activity.md +1 -1
  51. package/docs/architecture.md +12 -11
  52. package/docs/delivery.md +7 -5
  53. package/docs/multi-instance-bus.md +18 -8
  54. package/docs/public-api.md +2 -2
  55. package/docs/ui-style.md +2 -2
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +9 -1
  58. package/lib/bus-api.ts +11 -3
  59. package/lib/bus-follower.ts +417 -203
  60. package/lib/bus-leader.ts +88 -14
  61. package/lib/bus.ts +17 -15
  62. package/lib/delivery.ts +47 -19
  63. package/lib/extension.ts +51 -9
  64. package/lib/lifecycle.ts +229 -55
  65. package/lib/machine.ts +60 -0
  66. package/lib/media.ts +438 -172
  67. package/lib/model.ts +169 -74
  68. package/lib/polling.ts +2 -0
  69. package/lib/preview.ts +715 -566
  70. package/lib/queue.ts +5 -5
  71. package/lib/routing.ts +325 -238
  72. package/lib/runtime.ts +260 -104
  73. package/lib/sections.ts +0 -2
  74. package/lib/telegram-api.ts +61 -8
  75. package/lib/text-groups.ts +75 -136
  76. package/lib/thread-display.ts +15 -4
  77. package/lib/threads.ts +120 -36
  78. package/lib/turns.ts +8 -1
  79. package/lib/updates.ts +58 -21
  80. package/package.json +1 -1
package/lib/extension.ts CHANGED
@@ -70,6 +70,8 @@ const telegramBusProtocolIdentity =
70
70
  Bus.TELEGRAM_BUS_CAPABILITY_DIRECTORY_DISPLAY_FORMAT,
71
71
  Bus.TELEGRAM_BUS_CAPABILITY_SESSION_REPLACEMENT_INTENT,
72
72
  Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE,
73
+ Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND,
74
+ Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
73
75
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SAVE,
74
76
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_APPLY,
75
77
  Bus.TELEGRAM_BUS_CAPABILITY_LIVE_REBIND_SETTLE,
@@ -389,10 +391,6 @@ export default function (pi: Pi.ExtensionAPI) {
389
391
  buttonActionStore,
390
392
  configControls.getAssistantRenderingMode,
391
393
  );
392
- const pendingModelSwitchStore =
393
- Model.createPendingModelSwitchStore<
394
- Model.ScopedTelegramModel<ActivePiModel>
395
- >();
396
394
  const modelMenuRuntime = Menu.createTelegramModelMenuRuntime<ActivePiModel>();
397
395
  const sectionRegistry = Sections.createAndBindTelegramSectionRegistry();
398
396
 
@@ -424,8 +422,25 @@ export default function (pi: Pi.ExtensionAPI) {
424
422
  rawTelegramQueueStore,
425
423
  telegramTransportStampRuntime.getStamp,
426
424
  );
425
+ const captureCleanupDrain = Lifecycle.createTelegramSessionDrainObserver({
426
+ context: telegramSessionContextStore,
427
+ captureAuthority() {
428
+ const epoch = getCurrentLeaderEpoch();
429
+ return epoch === undefined ? undefined : {
430
+ epoch, operator: configStore.getAllowedUserId(), stamp: telegramTransportStampRuntime.getStamp(),
431
+ };
432
+ },
433
+ isAuthorityActive(authority) {
434
+ return getCurrentLeaderEpoch() === authority.epoch &&
435
+ configStore.getAllowedUserId() === authority.operator &&
436
+ telegramTransportStampRuntime.isActive(authority.stamp);
437
+ },
438
+ onDrained(ctx) { inboundRouteRuntime.onDeliverySettled(ctx); },
439
+ });
427
440
  const telegramApiTargetActivityRuntime =
428
- TelegramApi.createTelegramApiTargetActivityRuntime();
441
+ TelegramApi.createTelegramApiTargetActivityRuntime({
442
+ captureDrain: captureCleanupDrain,
443
+ });
429
444
  // Retirement protection and dead-owner reclamation must inspect the same journal sources.
430
445
  const workspaceRetirementJournalPorts = {
431
446
  getActiveTurnTarget: activeTurnRuntime.getTarget,
@@ -696,7 +711,10 @@ export default function (pi: Pi.ExtensionAPI) {
696
711
  hasDispatchPending: lifecycle.hasDispatchPending,
697
712
  isCompactionInProgress: lifecycle.isCompactionInProgress,
698
713
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
699
- hasPendingModelSwitch: pendingModelSwitchStore.has,
714
+ // Status is composed before the model-switch controller; it reads the controller's state when it renders.
715
+ hasPendingModelSwitch() {
716
+ return modelSwitchController.hasPendingSwitch();
717
+ },
700
718
  getQueuedItems: telegramQueueStore.getQueuedItems,
701
719
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
702
720
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
@@ -739,6 +757,11 @@ export default function (pi: Pi.ExtensionAPI) {
739
757
  const directTelegramApiRuntime =
740
758
  TelegramApi.createDefaultTelegramBridgeApiRuntime({
741
759
  getBotToken: configStore.getBotToken,
760
+ isThreadedMode() {
761
+ return threadStore.getBotState().threadMode === "enabled" ||
762
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
763
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
764
+ },
742
765
  getBotScope() {
743
766
  const config = configStore.get();
744
767
  return (
@@ -837,9 +860,13 @@ export default function (pi: Pi.ExtensionAPI) {
837
860
  lifecycle,
838
861
  typing,
839
862
  getDefaultChatId: proactivePushChatIdGetter,
863
+ isThreadedMode() {
864
+ return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
865
+ },
840
866
  sendTypingAction,
841
867
  updateStatus,
842
868
  isContextActive: telegramSessionContextStore.isCurrent,
869
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
843
870
  getTransportAuthority() {
844
871
  if (ownsTelegramDirectDelivery()) {
845
872
  const epoch = getCurrentLeaderEpoch();
@@ -875,6 +902,11 @@ export default function (pi: Pi.ExtensionAPI) {
875
902
  const deliveryTargetPolicyRuntime =
876
903
  Delivery.createTelegramDeliveryTargetPolicyRuntime({
877
904
  ownsDirect: lockRuntime.owns,
905
+ isThreadedMode() {
906
+ return threadStore.getBotState().threadMode === "enabled" ||
907
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
908
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
909
+ },
878
910
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
879
911
  getAllowedChatId: configStore.getAllowedUserId,
880
912
  getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
@@ -888,6 +920,7 @@ export default function (pi: Pi.ExtensionAPI) {
888
920
  const deliveryLifecycleRuntime =
889
921
  Delivery.createTelegramBridgeDeliveryLifecycleHooks({
890
922
  generationSeed: deliveryGenerationSeed,
923
+ captureDrain: captureCleanupDrain,
891
924
  getTargetPolicyView: deliveryTargetPolicyRuntime.getTargetPolicyView,
892
925
  getTransportStamp: telegramTransportStampRuntime.getStamp,
893
926
  isTransportStampActive: telegramTransportStampRuntime.isActive,
@@ -948,6 +981,7 @@ export default function (pi: Pi.ExtensionAPI) {
948
981
  publicationRuntime,
949
982
  } = Bindings.createTelegramActivityBindingRuntime({
950
983
  generation: deliveryGenerationSeed,
984
+ captureDrain: captureCleanupDrain,
951
985
  assistantOutput: {
952
986
  prepareTelegramPreview: previewRuntime.preparePublication,
953
987
  authority: {
@@ -1029,8 +1063,6 @@ export default function (pi: Pi.ExtensionAPI) {
1029
1063
  const modelSwitchController =
1030
1064
  Model.createTelegramModelSwitchControllerRuntime({
1031
1065
  isIdle,
1032
- getPendingModelSwitch: pendingModelSwitchStore.get,
1033
- setPendingModelSwitch: pendingModelSwitchStore.set,
1034
1066
  getActiveTurn: activeTurnRuntime.get,
1035
1067
  getAbortHandler: abort.getHandler,
1036
1068
  hasAbortHandler: abort.hasHandler,
@@ -1258,7 +1290,7 @@ export default function (pi: Pi.ExtensionAPI) {
1258
1290
  lifecycle: bridgeRuntime.lifecycle,
1259
1291
  isIdle,
1260
1292
  hasPendingMessages,
1261
- hasPendingControl: pendingModelSwitchStore.has,
1293
+ hasPendingControl: modelSwitchController.hasPendingSwitch,
1262
1294
  publication: publicationRuntime,
1263
1295
  activity: activityRuntime,
1264
1296
  delivery: deliveryLifecycleRuntime,
@@ -1436,6 +1468,14 @@ export default function (pi: Pi.ExtensionAPI) {
1436
1468
  )
1437
1469
  );
1438
1470
  },
1471
+ canUsePreDispatchRebind(recipient) {
1472
+ return lockRuntime.owns() && telegramBusFollowerRegistry.list().every(function (follower) {
1473
+ return Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND);
1474
+ }) && (recipient?.kind !== "follower" || Bus.hasTelegramBusCapability(
1475
+ telegramBusFollowerRegistry.get(recipient.instanceId)?.protocol,
1476
+ Bus.TELEGRAM_BUS_CAPABILITY_PRE_DISPATCH_REBIND_EXECUTION,
1477
+ ));
1478
+ },
1439
1479
  hasWorkspaceLiveRebindAuthority() {
1440
1480
  return (
1441
1481
  lockRuntime.owns() &&
@@ -2193,6 +2233,7 @@ export default function (pi: Pi.ExtensionAPI) {
2193
2233
  followerRegistry: telegramBusFollowerRegistry,
2194
2234
  authSecret: telegramBusAuthSecret,
2195
2235
  protocolIdentity: telegramBusProtocolIdentity,
2236
+ requiresPreDispatchRebindReader() { return threadStore.hasPreDispatchRebindEvidence(); },
2196
2237
  selectedMenuDelivery,
2197
2238
  startPolling: pollingAdmissionRuntime.start,
2198
2239
  stopPolling: pollingAdmissionRuntime.stop,
@@ -3029,6 +3070,7 @@ export default function (pi: Pi.ExtensionAPI) {
3029
3070
  isSessionContextActive(ctx) {
3030
3071
  return telegramSessionContextStore.isCurrent(ctx);
3031
3072
  },
3073
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
3032
3074
  isTurnTransportActive(turn) {
3033
3075
  return telegramTransportStampRuntime.isActive(turn.transportStamp);
3034
3076
  },
package/lib/lifecycle.ts CHANGED
@@ -29,6 +29,7 @@ import type {
29
29
  UIPromptEndEvent,
30
30
  UIPromptStartEvent,
31
31
  } from "./pi.ts";
32
+ import * as Machine from "./machine.ts";
32
33
  import * as Queue from "./queue.ts";
33
34
  import { formatTelegramConnectionFailure } from "./status.ts";
34
35
  import * as TextGroups from "./text-groups.ts";
@@ -411,6 +412,30 @@ export function createTelegramSessionContextStore<TContext>(
411
412
  };
412
413
  }
413
414
 
415
+ /** Capture drain hints at work admission; late completion never borrows a replacement session or transport. */
416
+ export function createTelegramSessionDrainObserver<TContext, TAuthority>(deps: {
417
+ context: Pick<TelegramSessionContextStore<TContext>, "get" | "getGeneration" | "isCurrent">;
418
+ captureAuthority: () => TAuthority | undefined;
419
+ isAuthorityActive: (authority: TAuthority) => boolean;
420
+ onDrained: (ctx: TContext) => void;
421
+ }): () => (() => void) | undefined {
422
+ return () => {
423
+ try {
424
+ const ctx = deps.context.get(), generation = deps.context.getGeneration(), authority = deps.captureAuthority();
425
+ if (ctx === undefined || authority === undefined) return undefined;
426
+ return () => {
427
+ queueMicrotask(() => {
428
+ try {
429
+ if (!deps.context.isCurrent(ctx, generation) || !deps.isAuthorityActive(authority)) return;
430
+ const emitted: unknown = deps.onDrained(ctx);
431
+ if (emitted) void Promise.resolve(emitted).catch(() => undefined);
432
+ } catch { /* A drain hint is observational, not execution authority. */ }
433
+ });
434
+ };
435
+ } catch { return undefined; }
436
+ };
437
+ }
438
+
414
439
  export function createTelegramSessionGenerationFence(
415
440
  store: TelegramSessionContextStore<ExtensionContext>,
416
441
  hooks: TelegramSessionLifecycleHooks,
@@ -670,6 +695,121 @@ export interface TelegramCompactionObserverRuntime<TContext> {
670
695
  onSessionShutdown: () => void;
671
696
  }
672
697
 
698
+ /** The whole compaction-observer state; only `transitionTelegramCompactionObserver` produces a new one. */
699
+ export interface TelegramCompactionObserverState {
700
+ /** The fallback timer's observation; a timer from any other one is inert. */
701
+ readonly observation: number | null;
702
+ /** The typing loop is the observer's to stop; a loop the agent already ran is never stopped by compaction. */
703
+ readonly typingOwned: boolean;
704
+ readonly nextObservation: number;
705
+ }
706
+
707
+ export type TelegramCompactionObserverEvent =
708
+ | { type: "beforeCompact" }
709
+ /** The outcome of the observer's own typing start, reported back by the shell. */
710
+ | { type: "typingStarted"; started: boolean }
711
+ | { type: "compacted" }
712
+ | { type: "compactFailed" }
713
+ | { type: "timerFired"; observation: number; contextActive: boolean }
714
+ | { type: "shutdown" };
715
+
716
+ export type TelegramCompactionObserverEffect =
717
+ | { type: "setInProgress"; inProgress: boolean }
718
+ | { type: "startTyping" }
719
+ | { type: "stopTyping" }
720
+ | { type: "updateStatus" }
721
+ | { type: "armTimer"; observation: number }
722
+ | { type: "cancelTimer" }
723
+ | { type: "recordTimeout" }
724
+ | { type: "abandoned" }
725
+ | { type: "requestDispatch" };
726
+
727
+ export function createTelegramCompactionObserverState(): TelegramCompactionObserverState {
728
+ return { observation: null, typingOwned: false, nextObservation: 0 };
729
+ }
730
+
731
+ /**
732
+ * Pure compaction-observer transition. A completion or failure always releases presence; the timeout releases only its
733
+ * own observation. Typing ownership is sticky: a repeated start while the observer's own loop runs reports `false`
734
+ * from the shared loop, and must not hand that loop to nobody.
735
+ */
736
+ export function transitionTelegramCompactionObserver(
737
+ state: TelegramCompactionObserverState,
738
+ event: TelegramCompactionObserverEvent,
739
+ ): Machine.TelegramMachineStep<
740
+ TelegramCompactionObserverState,
741
+ TelegramCompactionObserverEffect
742
+ > {
743
+ const cancel: TelegramCompactionObserverEffect[] =
744
+ state.observation === null ? [] : [{ type: "cancelTimer" }];
745
+ const release = (
746
+ ...tail: TelegramCompactionObserverEffect[]
747
+ ): TelegramCompactionObserverEffect[] => [
748
+ { type: "setInProgress", inProgress: false },
749
+ ...(state.typingOwned ? [{ type: "stopTyping" } as const] : []),
750
+ { type: "updateStatus" },
751
+ ...tail,
752
+ ];
753
+ switch (event.type) {
754
+ case "beforeCompact": {
755
+ const observation = state.nextObservation + 1;
756
+ return {
757
+ state: { ...state, observation, nextObservation: observation },
758
+ effects: [
759
+ { type: "setInProgress", inProgress: true },
760
+ { type: "startTyping" },
761
+ { type: "updateStatus" },
762
+ ...cancel,
763
+ { type: "armTimer", observation },
764
+ ],
765
+ };
766
+ }
767
+ case "typingStarted":
768
+ return {
769
+ state: { ...state, typingOwned: state.typingOwned || event.started },
770
+ effects: [],
771
+ };
772
+ case "compacted":
773
+ return {
774
+ state: { ...state, observation: null, typingOwned: false },
775
+ effects: [...cancel, ...release({ type: "requestDispatch" })],
776
+ };
777
+ case "compactFailed":
778
+ return {
779
+ state: { ...state, observation: null, typingOwned: false },
780
+ effects: [
781
+ ...cancel,
782
+ ...release({ type: "abandoned" }, { type: "requestDispatch" }),
783
+ ],
784
+ };
785
+ case "timerFired":
786
+ if (state.observation !== event.observation)
787
+ return { state, effects: [] };
788
+ // A replaced context consumes its observation without releasing the replacement's presence.
789
+ if (!event.contextActive)
790
+ return { state: { ...state, observation: null }, effects: [] };
791
+ return {
792
+ state: { ...state, observation: null, typingOwned: false },
793
+ effects: release(
794
+ { type: "recordTimeout" },
795
+ { type: "requestDispatch" },
796
+ ),
797
+ };
798
+ case "shutdown":
799
+ return {
800
+ state: { ...state, observation: null, typingOwned: false },
801
+ effects: [
802
+ ...cancel,
803
+ ...(state.typingOwned ? [{ type: "stopTyping" } as const] : []),
804
+ ],
805
+ };
806
+ }
807
+ }
808
+
809
+ /**
810
+ * The shell around `transitionTelegramCompactionObserver`: it fences inactive contexts, runs effects with the calling
811
+ * context, reports the typing start back and owns the fallback timer, which keeps the context it was armed under.
812
+ */
673
813
  export function createTelegramCompactionObserverRuntime<TContext>(
674
814
  deps: TelegramCompactionObserverRuntimeDeps<TContext>,
675
815
  ): TelegramCompactionObserverRuntime<TContext> {
@@ -677,74 +817,105 @@ export function createTelegramCompactionObserverRuntime<TContext>(
677
817
  const setTimer = deps.setTimer ?? setTimeout;
678
818
  const clearTimer = deps.clearTimer ?? clearTimeout;
679
819
  let fallbackTimer: TelegramLifecycleTimer | undefined;
680
- let observationGeneration = 0;
681
- let typingStartedByObserver = false;
682
- const clearFallbackTimer = (): void => {
683
- observationGeneration += 1;
684
- if (!fallbackTimer) return;
685
- clearTimer(fallbackTimer);
686
- fallbackTimer = undefined;
687
- };
688
- // Stop only a typing loop this observer started; a pre-existing agent-owned loop is preserved.
689
- const releaseCompactionPresence = (ctx: TContext): void => {
690
- deps.setCompactionInProgress(false);
691
- if (typingStartedByObserver) deps.stopTypingLoop?.();
692
- typingStartedByObserver = false;
693
- deps.updateStatus(ctx);
694
- };
695
- const requestDispatch = (): void => {
696
- deps.requestDeferredDispatchNextQueuedTelegramTurn(
697
- deps.dispatchNextQueuedTelegramTurn,
698
- );
820
+ // The context of the event being dispatched; effects run synchronously inside that dispatch.
821
+ let call: TContext | undefined;
822
+ const machine = Machine.createTelegramMachineRunner<
823
+ TelegramCompactionObserverState,
824
+ TelegramCompactionObserverEvent,
825
+ TelegramCompactionObserverEffect
826
+ >({
827
+ initial: createTelegramCompactionObserverState(),
828
+ transition: transitionTelegramCompactionObserver,
829
+ run(effect) {
830
+ const ctx = call as TContext;
831
+ switch (effect.type) {
832
+ case "setInProgress":
833
+ deps.setCompactionInProgress(effect.inProgress);
834
+ return;
835
+ case "startTyping": {
836
+ const result = deps.startTypingLoop?.(ctx);
837
+ machine.dispatch({
838
+ type: "typingStarted",
839
+ started: !!deps.startTypingLoop && result !== false,
840
+ });
841
+ return;
842
+ }
843
+ case "stopTyping":
844
+ deps.stopTypingLoop?.();
845
+ return;
846
+ case "updateStatus":
847
+ deps.updateStatus(ctx);
848
+ return;
849
+ case "armTimer": {
850
+ const armedCtx = ctx;
851
+ fallbackTimer = setTimer(() => {
852
+ fallbackTimer = undefined;
853
+ dispatchFor(armedCtx, {
854
+ type: "timerFired",
855
+ observation: effect.observation,
856
+ contextActive:
857
+ !deps.isContextActive || deps.isContextActive(armedCtx),
858
+ });
859
+ }, timeoutMs);
860
+ unrefTelegramLifecycleTimer(fallbackTimer);
861
+ return;
862
+ }
863
+ case "cancelTimer":
864
+ if (fallbackTimer !== undefined) clearTimer(fallbackTimer);
865
+ fallbackTimer = undefined;
866
+ return;
867
+ case "recordTimeout":
868
+ deps.recordRuntimeEvent?.(
869
+ "compact",
870
+ new Error("Compaction observer timed out"),
871
+ );
872
+ return;
873
+ case "abandoned":
874
+ deps.onCompactionAbandoned?.();
875
+ return;
876
+ case "requestDispatch":
877
+ // Observer expiry releases local presence, not Pi's eventual terminal result.
878
+ deps.requestDeferredDispatchNextQueuedTelegramTurn(
879
+ deps.dispatchNextQueuedTelegramTurn,
880
+ );
881
+ return;
882
+ }
883
+ },
884
+ });
885
+ const dispatchFor = (
886
+ ctx: TContext | undefined,
887
+ event: TelegramCompactionObserverEvent,
888
+ ): void => {
889
+ const previous = call;
890
+ call = ctx;
891
+ try {
892
+ machine.dispatch(event);
893
+ } finally {
894
+ call = previous;
895
+ }
699
896
  };
897
+ const isActive = (ctx: TContext): boolean =>
898
+ !deps.isContextActive || deps.isContextActive(ctx);
700
899
  return {
701
900
  onSessionBeforeCompact: (_event, ctx) => {
702
- if (deps.isContextActive && !deps.isContextActive(ctx)) return;
703
- deps.setCompactionInProgress(true);
704
- const typingStartResult = deps.startTypingLoop?.(ctx);
705
- typingStartedByObserver =
706
- !!deps.startTypingLoop && typingStartResult !== false;
707
- deps.updateStatus(ctx);
708
- clearFallbackTimer();
709
- const admittedGeneration = observationGeneration;
710
- fallbackTimer = setTimer(() => {
711
- if (observationGeneration !== admittedGeneration) return;
712
- observationGeneration += 1;
713
- fallbackTimer = undefined;
714
- if (deps.isContextActive && !deps.isContextActive(ctx)) return;
715
- releaseCompactionPresence(ctx);
716
- deps.recordRuntimeEvent?.(
717
- "compact",
718
- new Error("Compaction observer timed out"),
719
- );
720
- // Observer expiry releases local presence, not Pi's eventual terminal result.
721
- requestDispatch();
722
- }, timeoutMs);
723
- unrefTelegramLifecycleTimer(fallbackTimer);
901
+ if (isActive(ctx)) dispatchFor(ctx, { type: "beforeCompact" });
724
902
  },
725
903
  onSessionCompact: (_event, ctx) => {
726
- if (deps.isContextActive && !deps.isContextActive(ctx)) return;
727
- clearFallbackTimer();
728
- releaseCompactionPresence(ctx);
729
- requestDispatch();
904
+ if (isActive(ctx)) dispatchFor(ctx, { type: "compacted" });
730
905
  },
731
906
  onSessionCompactFailed: (_event, ctx) => {
732
- if (deps.isContextActive && !deps.isContextActive(ctx)) return;
733
- clearFallbackTimer();
734
- releaseCompactionPresence(ctx);
735
- deps.onCompactionAbandoned?.();
736
- requestDispatch();
907
+ if (isActive(ctx)) dispatchFor(ctx, { type: "compactFailed" });
737
908
  },
738
909
  onSessionShutdown: () => {
739
- clearFallbackTimer();
740
- if (typingStartedByObserver) deps.stopTypingLoop?.();
741
- typingStartedByObserver = false;
910
+ dispatchFor(undefined, { type: "shutdown" });
742
911
  },
743
912
  };
744
913
  }
745
914
 
746
915
  export interface TelegramMessageActivityTypingDeps<TContext> {
747
916
  hasActiveTurn: () => boolean;
917
+ isContextActive?: (ctx: TContext) => boolean;
918
+ getSessionGeneration?: () => number;
748
919
  startTypingLoop: (ctx: TContext) => void;
749
920
  onMessageStart: TelegramLifecycleRegistrationDeps["onMessageStart"];
750
921
  onMessageUpdate: TelegramLifecycleRegistrationDeps["onMessageUpdate"];
@@ -773,13 +944,16 @@ export function createTelegramMessageActivityTypingHooks<
773
944
  inner: (event: TEvent, ctx: ExtensionContext) => Promise<void>,
774
945
  ): Promise<void> => {
775
946
  const typedCtx = ctx as TContext;
776
- ensureTyping(typedCtx);
947
+ const generation = deps.getSessionGeneration?.();
948
+ const isCurrent = () => deps.isContextActive?.(typedCtx) !== false &&
949
+ (!deps.getSessionGeneration || Object.is(deps.getSessionGeneration(), generation));
950
+ if (isCurrent()) ensureTyping(typedCtx);
777
951
  try {
778
952
  await inner(event, ctx);
779
953
  } catch (error) {
780
954
  deps.recordRuntimeEvent?.("message-activity", error, { phase });
781
955
  } finally {
782
- ensureTyping(typedCtx);
956
+ if (isCurrent()) ensureTyping(typedCtx);
783
957
  }
784
958
  };
785
959
  return {
package/lib/machine.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Pure state-machine runner
3
+ * Zones: shared utils
4
+ * Owns committing a pure transition's next state, running the effects it returns and waking state waiters
5
+ * Knows nothing about the machines it drives: their state, events, effects, timers and transports stay with each domain
6
+ */
7
+
8
+ /** One transition result: the next state plus effects as data. A machine may add fields, such as a call result. */
9
+ export interface TelegramMachineStep<State, Effect> {
10
+ state: State;
11
+ effects: readonly Effect[];
12
+ }
13
+
14
+ export interface TelegramMachineRunner<State, Event, Step> {
15
+ readonly state: State;
16
+ /** Commits the next state, runs its effects (which may dispatch again), wakes waiters and returns the step. */
17
+ dispatch(event: Event): Step;
18
+ /** Resolves once `ready` holds for the committed state, checking now and after every later dispatch. */
19
+ waitFor(ready: (state: State) => boolean): Promise<void>;
20
+ }
21
+
22
+ export function createTelegramMachineRunner<
23
+ State,
24
+ Event,
25
+ Effect,
26
+ Step extends TelegramMachineStep<State, Effect> = TelegramMachineStep<
27
+ State,
28
+ Effect
29
+ >,
30
+ >(options: {
31
+ initial: State;
32
+ transition: (state: State, event: Event) => Step;
33
+ run: (effect: Effect) => void;
34
+ }): TelegramMachineRunner<State, Event, Step> {
35
+ let state = options.initial;
36
+ const waiters = new Set<() => void>();
37
+ return {
38
+ get state(): State {
39
+ return state;
40
+ },
41
+ dispatch(event: Event): Step {
42
+ const next = options.transition(state, event);
43
+ state = next.state;
44
+ for (const effect of next.effects) options.run(effect);
45
+ for (const waiter of [...waiters]) waiter();
46
+ return next;
47
+ },
48
+ waitFor(ready: (state: State) => boolean): Promise<void> {
49
+ return new Promise((resolve) => {
50
+ if (ready(state)) return resolve();
51
+ const waiter = (): void => {
52
+ if (!ready(state)) return;
53
+ waiters.delete(waiter);
54
+ resolve();
55
+ };
56
+ waiters.add(waiter);
57
+ });
58
+ },
59
+ };
60
+ }