@llblab/pi-telegram 0.54.3 → 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 +21 -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 +5 -1
  8. package/dist/lib/bindings.js +9 -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 +56 -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 +14 -2
  34. package/dist/lib/routing.js +546 -207
  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 +20 -5
  44. package/dist/lib/threads.js +111 -32
  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 +16 -15
  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 +8 -6
  56. package/lib/activity.ts +29 -5
  57. package/lib/bindings.ts +13 -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 +59 -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 +644 -283
  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 +130 -38
  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,
@@ -632,6 +647,13 @@ export default function (pi: Pi.ExtensionAPI) {
632
647
  instanceId: telegramInstanceId,
633
648
  listRecords: threadStore.list,
634
649
  listWorkspaceBindings: threadStore.listWorkspaceBindings,
650
+ resolveAutomaticDisplayTitle(binding) {
651
+ return ThreadDisplay.resolveTelegramInitialWorkspaceDisplayName({
652
+ bindings: threadStore.listWorkspaceBindings(),
653
+ binding,
654
+ mode: Config.resolveTelegramThreadDisplayMode(configStore.get()),
655
+ });
656
+ },
635
657
  getFollowerDisplayTitle:
636
658
  telegramBusFollowerRegistrationState.getDisplayTitle,
637
659
  getActiveTurnTarget: activeTurnRuntime.getTarget,
@@ -689,7 +711,10 @@ export default function (pi: Pi.ExtensionAPI) {
689
711
  hasDispatchPending: lifecycle.hasDispatchPending,
690
712
  isCompactionInProgress: lifecycle.isCompactionInProgress,
691
713
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
692
- 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
+ },
693
718
  getQueuedItems: telegramQueueStore.getQueuedItems,
694
719
  getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
695
720
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
@@ -732,6 +757,11 @@ export default function (pi: Pi.ExtensionAPI) {
732
757
  const directTelegramApiRuntime =
733
758
  TelegramApi.createDefaultTelegramBridgeApiRuntime({
734
759
  getBotToken: configStore.getBotToken,
760
+ isThreadedMode() {
761
+ return threadStore.getBotState().threadMode === "enabled" ||
762
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
763
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
764
+ },
735
765
  getBotScope() {
736
766
  const config = configStore.get();
737
767
  return (
@@ -830,9 +860,13 @@ export default function (pi: Pi.ExtensionAPI) {
830
860
  lifecycle,
831
861
  typing,
832
862
  getDefaultChatId: proactivePushChatIdGetter,
863
+ isThreadedMode() {
864
+ return deliveryTargetPolicyRuntime.getTargetPolicyView().threaded === true;
865
+ },
833
866
  sendTypingAction,
834
867
  updateStatus,
835
868
  isContextActive: telegramSessionContextStore.isCurrent,
869
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
836
870
  getTransportAuthority() {
837
871
  if (ownsTelegramDirectDelivery()) {
838
872
  const epoch = getCurrentLeaderEpoch();
@@ -868,6 +902,11 @@ export default function (pi: Pi.ExtensionAPI) {
868
902
  const deliveryTargetPolicyRuntime =
869
903
  Delivery.createTelegramDeliveryTargetPolicyRuntime({
870
904
  ownsDirect: lockRuntime.owns,
905
+ isThreadedMode() {
906
+ return threadStore.getBotState().threadMode === "enabled" ||
907
+ telegramBusFollowerRegistrationState.getTarget()?.threadId !== undefined ||
908
+ telegramBusLeaderState.getTarget()?.threadId !== undefined;
909
+ },
871
910
  isFollowerRegistered: telegramBusFollowerRegistrationState.isRegistered,
872
911
  getAllowedChatId: configStore.getAllowedUserId,
873
912
  getFollowerTarget: telegramBusFollowerRegistrationState.getTarget,
@@ -881,6 +920,7 @@ export default function (pi: Pi.ExtensionAPI) {
881
920
  const deliveryLifecycleRuntime =
882
921
  Delivery.createTelegramBridgeDeliveryLifecycleHooks({
883
922
  generationSeed: deliveryGenerationSeed,
923
+ captureDrain: captureCleanupDrain,
884
924
  getTargetPolicyView: deliveryTargetPolicyRuntime.getTargetPolicyView,
885
925
  getTransportStamp: telegramTransportStampRuntime.getStamp,
886
926
  isTransportStampActive: telegramTransportStampRuntime.isActive,
@@ -941,6 +981,7 @@ export default function (pi: Pi.ExtensionAPI) {
941
981
  publicationRuntime,
942
982
  } = Bindings.createTelegramActivityBindingRuntime({
943
983
  generation: deliveryGenerationSeed,
984
+ captureDrain: captureCleanupDrain,
944
985
  assistantOutput: {
945
986
  prepareTelegramPreview: previewRuntime.preparePublication,
946
987
  authority: {
@@ -1022,8 +1063,6 @@ export default function (pi: Pi.ExtensionAPI) {
1022
1063
  const modelSwitchController =
1023
1064
  Model.createTelegramModelSwitchControllerRuntime({
1024
1065
  isIdle,
1025
- getPendingModelSwitch: pendingModelSwitchStore.get,
1026
- setPendingModelSwitch: pendingModelSwitchStore.set,
1027
1066
  getActiveTurn: activeTurnRuntime.get,
1028
1067
  getAbortHandler: abort.getHandler,
1029
1068
  hasAbortHandler: abort.hasHandler,
@@ -1251,7 +1290,7 @@ export default function (pi: Pi.ExtensionAPI) {
1251
1290
  lifecycle: bridgeRuntime.lifecycle,
1252
1291
  isIdle,
1253
1292
  hasPendingMessages,
1254
- hasPendingControl: pendingModelSwitchStore.has,
1293
+ hasPendingControl: modelSwitchController.hasPendingSwitch,
1255
1294
  publication: publicationRuntime,
1256
1295
  activity: activityRuntime,
1257
1296
  delivery: deliveryLifecycleRuntime,
@@ -1429,6 +1468,14 @@ export default function (pi: Pi.ExtensionAPI) {
1429
1468
  )
1430
1469
  );
1431
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
+ },
1432
1479
  hasWorkspaceLiveRebindAuthority() {
1433
1480
  return (
1434
1481
  lockRuntime.owns() &&
@@ -2186,6 +2233,7 @@ export default function (pi: Pi.ExtensionAPI) {
2186
2233
  followerRegistry: telegramBusFollowerRegistry,
2187
2234
  authSecret: telegramBusAuthSecret,
2188
2235
  protocolIdentity: telegramBusProtocolIdentity,
2236
+ requiresPreDispatchRebindReader() { return threadStore.hasPreDispatchRebindEvidence(); },
2189
2237
  selectedMenuDelivery,
2190
2238
  startPolling: pollingAdmissionRuntime.start,
2191
2239
  stopPolling: pollingAdmissionRuntime.stop,
@@ -3022,10 +3070,12 @@ export default function (pi: Pi.ExtensionAPI) {
3022
3070
  isSessionContextActive(ctx) {
3023
3071
  return telegramSessionContextStore.isCurrent(ctx);
3024
3072
  },
3073
+ getSessionGeneration: telegramSessionContextStore.getGeneration,
3025
3074
  isTurnTransportActive(turn) {
3026
3075
  return telegramTransportStampRuntime.isActive(turn.transportStamp);
3027
3076
  },
3028
3077
  updateStatus,
3029
3078
  recordRuntimeEvent,
3079
+ onSessionSettled: inboundRouteRuntime.onSessionSettled,
3030
3080
  });
3031
3081
  }
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
+ }