@gajae-code/coding-agent 0.11.9 → 0.11.11

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 (150) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/dist/types/cli.d.ts +8 -1
  3. package/dist/types/config/model-profile-activation.d.ts +1 -0
  4. package/dist/types/config/model-profile-contract.d.ts +2 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/settings-schema.d.ts +57 -2
  7. package/dist/types/config/settings.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  9. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  11. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  12. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  13. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  14. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  15. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  16. package/dist/types/modes/components/welcome.d.ts +1 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  18. package/dist/types/modes/interactive-mode.d.ts +5 -1
  19. package/dist/types/runtime/memory-domain.d.ts +9 -0
  20. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  21. package/dist/types/runtime/memory-guard.d.ts +39 -0
  22. package/dist/types/runtime/memory-limit.d.ts +11 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  24. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  25. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  26. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  28. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  29. package/dist/types/sdk/bus/index.d.ts +11 -2
  30. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  31. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  32. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  33. package/dist/types/sdk/bus/telegram-daemon.d.ts +97 -8
  34. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  36. package/dist/types/sdk/session.d.ts +7 -0
  37. package/dist/types/sdk/startup-capability.d.ts +6 -0
  38. package/dist/types/session/agent-session.d.ts +11 -11
  39. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  40. package/dist/types/session/internal/managed-session-scope.d.ts +28 -1
  41. package/dist/types/session/session-manager.d.ts +34 -0
  42. package/dist/types/task/executor.d.ts +6 -0
  43. package/dist/types/task/provider-retry-status.d.ts +13 -0
  44. package/dist/types/task/types.d.ts +39 -0
  45. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  46. package/dist/types/tools/index.d.ts +3 -1
  47. package/dist/types/tools/resource-gc.d.ts +19 -0
  48. package/package.json +8 -8
  49. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  50. package/scripts/run-test-manifest.ts +6 -4
  51. package/src/cli/args.ts +14 -8
  52. package/src/cli/fast-help.ts +1 -1
  53. package/src/cli/notify-cli.ts +1 -0
  54. package/src/cli.ts +48 -2
  55. package/src/commands/sdk.ts +91 -7
  56. package/src/config/model-profile-activation.ts +1 -0
  57. package/src/config/model-profile-contract.ts +13 -0
  58. package/src/config/model-profiles.ts +31 -7
  59. package/src/config/model-registry.ts +4 -2
  60. package/src/config/settings-schema.ts +61 -2
  61. package/src/config/settings.ts +8 -0
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  64. package/src/eval/js/tool-bridge.ts +1 -1
  65. package/src/extensibility/extensions/runner.ts +4 -0
  66. package/src/extensibility/extensions/types.ts +14 -0
  67. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  68. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  69. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  70. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  71. package/src/gjc-runtime/linux-proc.ts +70 -36
  72. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  73. package/src/gjc-runtime/state-runtime.ts +48 -11
  74. package/src/gjc-runtime/state-writer.ts +1 -1
  75. package/src/hooks/skill-state.ts +23 -1
  76. package/src/internal-urls/docs-index.generated.ts +7 -7
  77. package/src/internal-urls/local-protocol.ts +23 -4
  78. package/src/modes/acp/acp-agent.ts +554 -60
  79. package/src/modes/acp/acp-event-mapper.ts +93 -6
  80. package/src/modes/components/model-selector.ts +15 -1
  81. package/src/modes/components/notifications-settings-editor.ts +1 -1
  82. package/src/modes/components/queue-pane.ts +55 -9
  83. package/src/modes/components/queued-message-selector.ts +53 -10
  84. package/src/modes/components/tool-status-header.ts +1 -4
  85. package/src/modes/components/welcome.ts +18 -3
  86. package/src/modes/controllers/command-controller.ts +4 -0
  87. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  88. package/src/modes/controllers/input-controller.ts +44 -3
  89. package/src/modes/controllers/selector-controller.ts +38 -8
  90. package/src/modes/interactive-mode.ts +65 -26
  91. package/src/modes/runtime-init.ts +1 -0
  92. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  93. package/src/prompts/agents/architect.md +8 -1
  94. package/src/prompts/agents/critic.md +8 -1
  95. package/src/prompts/agents/executor.md +1 -1
  96. package/src/prompts/agents/planner.md +8 -1
  97. package/src/runtime/memory-domain.ts +66 -0
  98. package/src/runtime/memory-guard-contract.ts +73 -0
  99. package/src/runtime/memory-guard.ts +292 -0
  100. package/src/runtime/memory-limit.ts +50 -0
  101. package/src/runtime-mcp/manager.ts +26 -6
  102. package/src/sdk/acp/adapter.ts +8 -1
  103. package/src/sdk/acp/final-text.ts +40 -0
  104. package/src/sdk/acp/mcp.ts +26 -0
  105. package/src/sdk/broker/identity.ts +22 -12
  106. package/src/sdk/broker/lifecycle.ts +82 -1
  107. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  108. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  109. package/src/sdk/bus/config.ts +1 -1
  110. package/src/sdk/bus/daemon-paths.ts +2 -0
  111. package/src/sdk/bus/index.ts +222 -27
  112. package/src/sdk/bus/notification-service.ts +19 -5
  113. package/src/sdk/bus/operator-runtime.ts +31 -5
  114. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  115. package/src/sdk/bus/telegram-cli.ts +6 -13
  116. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  117. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  118. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  119. package/src/sdk/bus/telegram-daemon.ts +1191 -164
  120. package/src/sdk/host/control/dispatch.ts +2 -0
  121. package/src/sdk/host/control/operations.ts +1 -0
  122. package/src/sdk/host/host.ts +2 -2
  123. package/src/sdk/lifecycle-session.ts +19 -3
  124. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  125. package/src/sdk/protocol/operation-registry.ts +1 -0
  126. package/src/sdk/session.ts +24 -1
  127. package/src/sdk/startup-capability.ts +6 -0
  128. package/src/session/agent-session.ts +290 -132
  129. package/src/session/fallback-chain-controller.ts +47 -8
  130. package/src/session/internal/managed-session-scope.ts +173 -127
  131. package/src/session/internal/native-publish-outcome.ts +8 -3
  132. package/src/session/session-manager.ts +608 -25
  133. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  134. package/src/task/executor.ts +152 -42
  135. package/src/task/index.ts +2 -0
  136. package/src/task/provider-retry-status.ts +88 -0
  137. package/src/task/render.ts +45 -26
  138. package/src/task/types.ts +25 -11
  139. package/src/task/ultragoal-redteam-activation.ts +75 -0
  140. package/src/tools/bash-allowed-prefixes.ts +45 -1
  141. package/src/tools/index.ts +3 -1
  142. package/src/tools/resource-gc.ts +496 -120
  143. package/src/tools/subagent-render.ts +74 -16
  144. package/src/tools/subagent.ts +2 -0
  145. package/src/workflow/workflow-intent-diff.ts +4 -1
  146. package/vendor/insane-search/MANIFEST.json +4 -1
  147. package/vendor/insane-search/engine/bias_check.py +5 -0
  148. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  149. package/vendor/insane-search/engine/transport.py +13 -2
  150. package/vendor/insane-search/engine/validators.py +4 -1
@@ -31,7 +31,7 @@ import {
31
31
  releaseDaemonTransitionLock,
32
32
  sanitizeDiagnostic,
33
33
  } from "./notification-service";
34
- import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
34
+ import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
35
35
  import {
36
36
  type DaemonProcessReference,
37
37
  type TelegramDaemonControlDeps,
@@ -39,7 +39,7 @@ import {
39
39
  } from "./telegram-daemon-control";
40
40
  import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
41
41
 
42
- export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
42
+ export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION, SERVING_EPOCH } from "./telegram-daemon-contract";
43
43
 
44
44
  import {
45
45
  buildButtonGrid,
@@ -132,6 +132,8 @@ export interface DaemonState {
132
132
  * notification wire protocol version. Absent on pre-generation state.
133
133
  */
134
134
  generation?: number;
135
+ /** Lifecycle-serving compatibility epoch; absent pre-epoch records are epoch 1. */
136
+ servingEpoch?: number;
135
137
  stoppedAt?: number;
136
138
  }
137
139
  export interface TelegramDaemonFs {
@@ -191,8 +193,20 @@ export const CLIENT_PING_PONG_CAPABILITY = "client_ping_pong";
191
193
  /** Capability required for typed controls and semantic Selected acknowledgement frames. */
192
194
  export const ASK_SELECTED_ACK_CAPABILITY = "ask_selected_ack_v1";
193
195
  export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
194
- /** Capability required for tool lifecycle and reasoning-summary frames. */
195
- export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
196
+ /** Capability for the closed tool phase set: started, completed, failed, and cancelled. */
197
+ export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v2";
198
+ /** Receive-only compatibility capability for pre-v2 hosts. */
199
+ export const LEGACY_TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
200
+ type ToolActivityCapability = "v1" | "v2";
201
+
202
+ function negotiateToolActivityCapability(
203
+ current: ToolActivityCapability | undefined,
204
+ capabilities: unknown[],
205
+ ): ToolActivityCapability | undefined {
206
+ if (current === "v2" || capabilities.includes(TOOL_ACTIVITY_CAPABILITY)) return "v2";
207
+ if (current === "v1" || capabilities.includes(LEGACY_TOOL_ACTIVITY_CAPABILITY)) return "v1";
208
+ return undefined;
209
+ }
196
210
 
197
211
  const nodeFs: TelegramDaemonFs = {
198
212
  ...(fs.promises as unknown as TelegramDaemonFs),
@@ -241,6 +255,18 @@ const RELOAD_FRESHNESS_WAIT_MS = 15_000;
241
255
  const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
242
256
  const RELOAD_CONTROLLER_KILL_MS = 3_000;
243
257
  const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
258
+ const ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS = 100;
259
+ const ROOTS_REGISTRATION_LOCK_HEADROOM_MS = 5_000;
260
+ const ROOTS_REGISTRATION_LOCK_RETRIES =
261
+ Math.ceil(
262
+ (RELOAD_CONTROLLER_GRACEFUL_MS + RELOAD_CONTROLLER_KILL_MS + ROOTS_REGISTRATION_LOCK_HEADROOM_MS) /
263
+ ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
264
+ ) + 1;
265
+ const ROOTS_REGISTRATION_LOCK_OPTIONS = {
266
+ staleMs: 10_000,
267
+ retries: ROOTS_REGISTRATION_LOCK_RETRIES,
268
+ retryDelayMs: ROOTS_REGISTRATION_LOCK_RETRY_DELAY_MS,
269
+ };
244
270
 
245
271
  /**
246
272
  * File-lock options whose acquisition budget covers the full reload-reservation
@@ -656,6 +682,162 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
656
682
  );
657
683
  }
658
684
 
685
+ type OwnerHeartbeatSidecar = {
686
+ pid: number;
687
+ incarnation: string;
688
+ ownerId: string;
689
+ acquisitionId: string;
690
+ heartbeatAt: number;
691
+ };
692
+
693
+ function ownerTagFromState(
694
+ state: DaemonState | undefined,
695
+ ): Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined {
696
+ if (
697
+ !state ||
698
+ !hasSafeDaemonStateShape(state) ||
699
+ state.stoppedAt !== undefined ||
700
+ !state.ownerId ||
701
+ !state.acquisitionId
702
+ )
703
+ return undefined;
704
+ return {
705
+ pid: state.pid,
706
+ incarnation: state.incarnation,
707
+ ownerId: state.ownerId,
708
+ acquisitionId: state.acquisitionId,
709
+ };
710
+ }
711
+
712
+ function sidecarMatchesOwnerTag(
713
+ sidecar: OwnerHeartbeatSidecar | undefined,
714
+ tag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined,
715
+ ): sidecar is OwnerHeartbeatSidecar {
716
+ return Boolean(
717
+ sidecar &&
718
+ tag &&
719
+ validDaemonPid(sidecar.pid) &&
720
+ isProcessIncarnation(sidecar.incarnation) &&
721
+ typeof sidecar.ownerId === "string" &&
722
+ typeof sidecar.acquisitionId === "string" &&
723
+ Number.isSafeInteger(sidecar.heartbeatAt) &&
724
+ sidecar.pid === tag.pid &&
725
+ sidecar.incarnation === tag.incarnation &&
726
+ sidecar.ownerId === tag.ownerId &&
727
+ sidecar.acquisitionId === tag.acquisitionId,
728
+ );
729
+ }
730
+
731
+ /**
732
+ * A corrupt, truncated, or otherwise unreadable sidecar must never break a
733
+ * freshness read: the sidecar is advisory-only, so any read/parse failure
734
+ * degrades to "no sidecar" (state-floor freshness), never a throw.
735
+ */
736
+ async function readSidecarLenient(fsImpl: TelegramDaemonFs, file: string): Promise<OwnerHeartbeatSidecar | undefined> {
737
+ try {
738
+ const parsed: unknown = JSON.parse(await fsImpl.readFile(file, "utf8"));
739
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
740
+ ? (parsed as OwnerHeartbeatSidecar)
741
+ : undefined;
742
+ } catch {
743
+ return undefined;
744
+ }
745
+ }
746
+
747
+ export interface OwnerFreshnessSnapshot {
748
+ ownerTag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | null;
749
+ effectiveHeartbeatAt: number | undefined;
750
+ legacyEmbedded: boolean;
751
+ state: DaemonState | undefined;
752
+ }
753
+
754
+ export async function readOwnerFreshnessSnapshot(input: {
755
+ settings: Settings;
756
+ fs?: TelegramDaemonFs;
757
+ }): Promise<OwnerFreshnessSnapshot> {
758
+ const fsImpl = input.fs ?? nodeFs;
759
+ const paths = daemonPaths(input.settings.getAgentDir());
760
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
761
+ const legacyEmbedded = Boolean(
762
+ state && (isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state)),
763
+ );
764
+ const tag = ownerTagFromState(state);
765
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
766
+ const ownerTag = tag && ownershipLockMatchesState(lock, state) ? tag : null;
767
+ if (legacyEmbedded) return { ownerTag, effectiveHeartbeatAt: state?.heartbeatAt, legacyEmbedded, state };
768
+ const sidecar = await readSidecarLenient(fsImpl, paths.heartbeat);
769
+ const rereadState = await readJson<DaemonState>(fsImpl, paths.state);
770
+ const rereadLock = await readOwnershipLock(fsImpl, paths.lock);
771
+ const rereadTag = ownerTagFromState(rereadState);
772
+ const stableTag = rereadTag && ownershipLockMatchesState(rereadLock, rereadState) ? rereadTag : null;
773
+ const stable =
774
+ ownerTag && stableTag && sidecarMatchesOwnerTag({ ...ownerTag, heartbeatAt: 0 }, stableTag) ? stableTag : null;
775
+ const effectiveHeartbeatAt = stable
776
+ ? sidecarMatchesOwnerTag(sidecar, stable)
777
+ ? Math.max(rereadState?.heartbeatAt ?? 0, sidecar.heartbeatAt)
778
+ : rereadState?.heartbeatAt
779
+ : undefined;
780
+ return {
781
+ ownerTag: stable,
782
+ effectiveHeartbeatAt,
783
+ legacyEmbedded: false,
784
+ state: rereadState,
785
+ };
786
+ }
787
+
788
+ /** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */
789
+ export async function renewOwnerHeartbeatSidecar(input: {
790
+ settings: Settings;
791
+ ownerId: string;
792
+ acquisitionId?: string;
793
+ fs?: TelegramDaemonFs;
794
+ now?: () => number;
795
+ pid?: number;
796
+ pidIncarnation?: (pid: number) => string | undefined;
797
+ }): Promise<boolean> {
798
+ const fsImpl = input.fs ?? nodeFs;
799
+ const paths = daemonPaths(input.settings.getAgentDir());
800
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
801
+ const pid = input.pid ?? state?.pid;
802
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
803
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
804
+ if (
805
+ !state ||
806
+ !validDaemonPid(pid) ||
807
+ !isProcessIncarnation(incarnation) ||
808
+ state.pid !== pid ||
809
+ state.incarnation !== incarnation ||
810
+ state.ownerId !== input.ownerId ||
811
+ state.acquisitionId !== acquisitionId ||
812
+ state.stoppedAt !== undefined
813
+ )
814
+ return false;
815
+ const sidecar: OwnerHeartbeatSidecar = {
816
+ pid,
817
+ incarnation,
818
+ ownerId: input.ownerId,
819
+ acquisitionId,
820
+ heartbeatAt: (input.now ?? Date.now)(),
821
+ };
822
+ const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
823
+ await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
824
+ await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
825
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
826
+ if (!ownershipLockMatchesState(lock, state)) {
827
+ await fsImpl.unlink(tmp).catch(() => undefined);
828
+ return false;
829
+ }
830
+ // Revalidate the exact lock after the temporary sidecar exists so a stale
831
+ // writer cannot publish after ownership moved during its write.
832
+ const finalLock = await readOwnershipLock(fsImpl, paths.lock);
833
+ if (!ownershipLockMatchesState(finalLock, state)) {
834
+ await fsImpl.unlink(tmp).catch(() => undefined);
835
+ return false;
836
+ }
837
+ await fsImpl.rename(tmp, paths.heartbeat);
838
+ return true;
839
+ }
840
+
659
841
  function ownershipLockMatchesStoppedState(
660
842
  lock: OwnershipLockRead,
661
843
  state: unknown,
@@ -800,23 +982,29 @@ async function ownershipLockIsReclaimable(input: {
800
982
  );
801
983
  }
802
984
 
803
- interface NotificationRootRegistration {
985
+ export interface NotificationRootRegistration {
804
986
  root?: string;
805
987
  managed?: boolean;
988
+ token?: string;
806
989
  }
807
990
 
808
- async function readNotificationRootRegistration(input: {
991
+ export async function readNotificationRootRegistration(input: {
809
992
  settings: Settings;
810
993
  sessionId: string;
811
994
  fs?: TelegramDaemonFs;
812
995
  }): Promise<NotificationRootRegistration> {
813
996
  const fsImpl = input.fs ?? nodeFs;
814
- const current = await readJson<{ sessions?: Record<string, string>; managedRoots?: string[] }>(
815
- fsImpl,
816
- daemonPaths(input.settings.getAgentDir()).roots,
817
- );
997
+ const current = await readJson<{
998
+ sessions?: Record<string, string>;
999
+ managedRoots?: string[];
1000
+ registrationTokens?: Record<string, string>;
1001
+ }>(fsImpl, daemonPaths(input.settings.getAgentDir()).roots);
818
1002
  const root = current?.sessions?.[input.sessionId];
819
- return { root, managed: root !== undefined && current?.managedRoots?.includes(root) === true };
1003
+ return {
1004
+ root,
1005
+ managed: root !== undefined && current?.managedRoots?.includes(root) === true,
1006
+ token: current?.registrationTokens?.[input.sessionId],
1007
+ };
820
1008
  }
821
1009
 
822
1010
  /** Restore a session root only if this ensure operation still owns its registration. */
@@ -824,6 +1012,8 @@ async function restoreNotificationRootRegistration(input: {
824
1012
  settings: Settings;
825
1013
  sessionId: string;
826
1014
  registeredRoot: string;
1015
+ /** Token minted by this operation's own registration; a newer replacement registration's token fences the rollback. */
1016
+ registeredToken?: string;
827
1017
  previous: NotificationRootRegistration;
828
1018
  fs?: TelegramDaemonFs;
829
1019
  }): Promise<void> {
@@ -834,14 +1024,24 @@ async function restoreNotificationRootRegistration(input: {
834
1024
  paths.roots,
835
1025
  async () => {
836
1026
  const current =
837
- (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
838
- fsImpl,
839
- paths.roots,
840
- )) ?? {};
1027
+ (await readJson<{
1028
+ roots?: string[];
1029
+ managedRoots?: string[];
1030
+ sessions?: Record<string, string>;
1031
+ registrationTokens?: Record<string, string>;
1032
+ }>(fsImpl, paths.roots)) ?? {};
841
1033
  const sessions = { ...(current.sessions ?? {}) };
842
1034
  if (sessions[input.sessionId] !== input.registeredRoot) return;
1035
+ if (
1036
+ current.registrationTokens?.[input.sessionId] !== undefined &&
1037
+ current.registrationTokens[input.sessionId] !== input.registeredToken
1038
+ )
1039
+ return;
1040
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
843
1041
  if (input.previous.root) sessions[input.sessionId] = input.previous.root;
844
1042
  else delete sessions[input.sessionId];
1043
+ if (input.previous.token !== undefined) registrationTokens[input.sessionId] = input.previous.token;
1044
+ else delete registrationTokens[input.sessionId];
845
1045
  const referencedRoots = new Set(Object.values(sessions));
846
1046
  const roots = new Set(current.roots ?? []);
847
1047
  const managedRoots = new Set(current.managedRoots ?? []);
@@ -859,33 +1059,44 @@ async function restoreNotificationRootRegistration(input: {
859
1059
  roots: Array.from(roots).sort(),
860
1060
  managedRoots: Array.from(managedRoots).sort(),
861
1061
  sessions,
1062
+ registrationTokens,
862
1063
  });
863
1064
  },
864
1065
  { staleMs: 10_000 },
865
1066
  );
866
1067
  }
867
1068
 
1069
+ export interface RegisterNotificationRootResult {
1070
+ root: string;
1071
+ /** Per-registration ownership token fencing a stale same-session cleanup from deleting a live replacement registration. */
1072
+ token: string;
1073
+ }
1074
+
868
1075
  export async function registerNotificationRoot(input: {
869
1076
  settings: Settings;
870
1077
  cwd: string;
871
1078
  sessionId: string;
872
1079
  fs?: TelegramDaemonFs;
873
- }): Promise<string> {
1080
+ }): Promise<RegisterNotificationRootResult> {
874
1081
  const fsImpl = input.fs ?? nodeFs;
875
1082
  const paths = daemonPaths(input.settings.getAgentDir());
876
1083
  await ensureDir(fsImpl, paths.dir);
877
1084
  const root = notificationRootForCwd(input.cwd);
1085
+ const token = crypto.randomUUID();
878
1086
  await withFileLock(
879
1087
  paths.roots,
880
1088
  async () => {
881
1089
  const current =
882
- (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
883
- fsImpl,
884
- paths.roots,
885
- )) ?? {};
1090
+ (await readJson<{
1091
+ roots?: string[];
1092
+ managedRoots?: string[];
1093
+ sessions?: Record<string, string>;
1094
+ registrationTokens?: Record<string, string>;
1095
+ }>(fsImpl, paths.roots)) ?? {};
886
1096
  const roots = new Set(current.roots ?? []);
887
1097
  const managedRoots = new Set(current.managedRoots ?? []);
888
1098
  const sessions = { ...(current.sessions ?? {}) };
1099
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
889
1100
  const previousRoot = sessions[input.sessionId];
890
1101
  const rootAlreadyPresent = roots.has(root);
891
1102
  roots.add(root);
@@ -893,6 +1104,7 @@ export async function registerNotificationRoot(input: {
893
1104
  // survive a later session rollback or unregister.
894
1105
  if (!rootAlreadyPresent) managedRoots.add(root);
895
1106
  sessions[input.sessionId] = root;
1107
+ registrationTokens[input.sessionId] = token;
896
1108
  if (previousRoot && previousRoot !== root && managedRoots.has(previousRoot)) {
897
1109
  const previousStillReferenced = Object.values(sessions).includes(previousRoot);
898
1110
  if (!previousStillReferenced) {
@@ -905,16 +1117,19 @@ export async function registerNotificationRoot(input: {
905
1117
  roots: Array.from(roots).sort(),
906
1118
  managedRoots: Array.from(managedRoots).sort(),
907
1119
  sessions,
1120
+ registrationTokens,
908
1121
  });
909
1122
  },
910
- { staleMs: 10_000 },
1123
+ ROOTS_REGISTRATION_LOCK_OPTIONS,
911
1124
  );
912
- return root;
1125
+ return { root, token };
913
1126
  }
914
1127
 
915
1128
  export interface UnregisterNotificationRootResult {
916
1129
  root: string;
917
1130
  remainingRoots: number;
1131
+ /** Opaque fingerprint of the exact registry written by this successful unregister. */
1132
+ registryFingerprint?: string;
918
1133
  }
919
1134
 
920
1135
  /** Remove one session's Telegram scan root without disturbing other live session roots. */
@@ -923,20 +1138,19 @@ export async function unregisterNotificationRoot(input: {
923
1138
  cwd: string;
924
1139
  sessionId: string;
925
1140
  fs?: TelegramDaemonFs;
1141
+ /** Ownership token minted by this runtime's own registration; a stale cleanup presenting an older token never deletes a replacement registration. */
1142
+ registrationToken?: string;
926
1143
  }): Promise<UnregisterNotificationRootResult> {
927
1144
  const fsImpl = input.fs ?? nodeFs;
928
1145
  const paths = daemonPaths(input.settings.getAgentDir());
929
1146
  const root = notificationRootForCwd(input.cwd);
930
1147
  await ensureDir(fsImpl, paths.dir);
931
1148
  let remainingRoots = 0;
1149
+ let registryFingerprint: string | undefined;
932
1150
  await withFileLock(
933
1151
  paths.roots,
934
1152
  async () => {
935
- const current = await readJson<{
936
- roots?: string[];
937
- managedRoots?: string[];
938
- sessions?: Record<string, string>;
939
- }>(fsImpl, paths.roots);
1153
+ const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
940
1154
  if (!current) return;
941
1155
  const sessions = { ...(current.sessions ?? {}) };
942
1156
  // A stale cleanup from a previous registration must not remove a newer
@@ -945,7 +1159,19 @@ export async function unregisterNotificationRoot(input: {
945
1159
  remainingRoots = (current.roots ?? []).length;
946
1160
  return;
947
1161
  }
1162
+ // Same sessionId + same cwd re-registration records an identical root, so
1163
+ // the root match alone cannot distinguish a replaced registration. Any
1164
+ // token-bearing registration requires the caller to present that exact token;
1165
+ // only genuinely legacy token-less rows retain root-match cleanup behavior.
1166
+ const recordedToken = current.registrationTokens?.[input.sessionId];
1167
+ if (recordedToken !== undefined && recordedToken !== input.registrationToken) {
1168
+ logger.warn("notifications: fenced stale Telegram root unregister against a live replacement registration");
1169
+ remainingRoots = (current.roots ?? []).length;
1170
+ return;
1171
+ }
948
1172
  delete sessions[input.sessionId];
1173
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
1174
+ delete registrationTokens[input.sessionId];
949
1175
  const rootStillReferenced = Object.values(sessions).includes(root);
950
1176
  const managedRoots = new Set(current.managedRoots ?? []);
951
1177
  const roots = (current.roots ?? []).filter(
@@ -953,16 +1179,46 @@ export async function unregisterNotificationRoot(input: {
953
1179
  );
954
1180
  if (!rootStillReferenced) managedRoots.delete(root);
955
1181
  remainingRoots = roots.length;
956
- await writeJsonAtomic(fsImpl, paths.roots, {
1182
+ const nextRegistry: NotificationRootsRegistry = {
957
1183
  version: 1,
958
1184
  roots: Array.from(new Set(roots)).sort(),
959
1185
  managedRoots: Array.from(managedRoots).sort(),
960
1186
  sessions,
961
- });
1187
+ registrationTokens,
1188
+ };
1189
+ await writeJsonAtomic(fsImpl, paths.roots, nextRegistry);
1190
+ registryFingerprint = notificationRootRegistryFingerprint(nextRegistry);
1191
+ },
1192
+ { staleMs: 10_000 },
1193
+ );
1194
+ return registryFingerprint === undefined ? { root, remainingRoots } : { root, remainingRoots, registryFingerprint };
1195
+ }
1196
+
1197
+ /** Run an action while the roots registry lock excludes any registration or unregister. */
1198
+ export async function withNotificationRootRegistryFence(input: {
1199
+ settings: Settings;
1200
+ registryFingerprint: string;
1201
+ action: () => Promise<void>;
1202
+ fs?: TelegramDaemonFs;
1203
+ }): Promise<boolean> {
1204
+ const fsImpl = input.fs ?? nodeFs;
1205
+ const paths = daemonPaths(input.settings.getAgentDir());
1206
+ await ensureDir(fsImpl, paths.dir);
1207
+ return await withFileLock(
1208
+ paths.roots,
1209
+ async () => {
1210
+ const current = await readJson<NotificationRootsRegistry>(fsImpl, paths.roots);
1211
+ if (
1212
+ !current ||
1213
+ (current.roots ?? []).length !== 0 ||
1214
+ notificationRootRegistryFingerprint(current) !== input.registryFingerprint
1215
+ )
1216
+ return false;
1217
+ await input.action();
1218
+ return true;
962
1219
  },
963
1220
  { staleMs: 10_000 },
964
1221
  );
965
- return { root, remainingRoots };
966
1222
  }
967
1223
 
968
1224
  function notificationRootForCwd(cwd: string): string {
@@ -998,8 +1254,32 @@ type NotificationRootsRegistry = {
998
1254
  roots?: string[];
999
1255
  managedRoots?: string[];
1000
1256
  sessions?: Record<string, string>;
1257
+ registrationTokens?: Record<string, string>;
1001
1258
  };
1002
1259
 
1260
+ function notificationRootRegistryFingerprint(registry: NotificationRootsRegistry): string {
1261
+ return crypto
1262
+ .createHash("sha256")
1263
+ .update(
1264
+ JSON.stringify({
1265
+ version: registry.version,
1266
+ roots: Array.from(new Set(registry.roots ?? [])).sort(),
1267
+ managedRoots: Array.from(new Set(registry.managedRoots ?? [])).sort(),
1268
+ sessions: Object.fromEntries(
1269
+ Object.entries(registry.sessions ?? {}).sort(([left], [right]) =>
1270
+ left < right ? -1 : left > right ? 1 : 0,
1271
+ ),
1272
+ ),
1273
+ registrationTokens: Object.fromEntries(
1274
+ Object.entries(registry.registrationTokens ?? {}).sort(([left], [right]) =>
1275
+ left < right ? -1 : left > right ? 1 : 0,
1276
+ ),
1277
+ ),
1278
+ }),
1279
+ )
1280
+ .digest("hex");
1281
+ }
1282
+
1003
1283
  /**
1004
1284
  * Drop permanently missing scan roots from the durable registry under the roots
1005
1285
  * lock. Session map entries that pointed only at those roots are removed too.
@@ -1022,6 +1302,7 @@ export async function pruneMissingNotificationRoots(input: {
1022
1302
  const roots = [...(current.roots ?? [])];
1023
1303
  const managedRoots = new Set(current.managedRoots ?? []);
1024
1304
  const sessions = { ...(current.sessions ?? {}) };
1305
+ const registrationTokens = { ...(current.registrationTokens ?? {}) };
1025
1306
  const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
1026
1307
  const survivors: string[] = [];
1027
1308
  for (const root of roots) {
@@ -1052,7 +1333,10 @@ export async function pruneMissingNotificationRoots(input: {
1052
1333
  pruned.push(root);
1053
1334
  managedRoots.delete(root);
1054
1335
  for (const [sessionId, mapped] of Object.entries(sessions)) {
1055
- if (mapped === root) delete sessions[sessionId];
1336
+ if (mapped === root) {
1337
+ delete sessions[sessionId];
1338
+ delete registrationTokens[sessionId];
1339
+ }
1056
1340
  }
1057
1341
  }
1058
1342
  }
@@ -1063,6 +1347,7 @@ export async function pruneMissingNotificationRoots(input: {
1063
1347
  roots: Array.from(new Set(survivors)).sort(),
1064
1348
  managedRoots: Array.from(managedRoots).sort(),
1065
1349
  sessions,
1350
+ registrationTokens,
1066
1351
  });
1067
1352
  },
1068
1353
  { staleMs: 10_000 },
@@ -1220,7 +1505,9 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
1220
1505
  candidate.version === DAEMON_VERSION &&
1221
1506
  (candidate.generation === undefined ||
1222
1507
  (Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
1223
- (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
1508
+ (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)) &&
1509
+ (candidate.servingEpoch === undefined ||
1510
+ (Number.isSafeInteger(candidate.servingEpoch) && (candidate.servingEpoch as number) > 0)),
1224
1511
  );
1225
1512
  }
1226
1513
 
@@ -1421,6 +1708,7 @@ async function legacyParentHandoffDecision(input: {
1421
1708
  > {
1422
1709
  const { state } = input;
1423
1710
  if (!input.pidAlive(state.pid)) return undefined;
1711
+ if (state.generation !== 3) return { acquired: false, attached: false, blocked: true };
1424
1712
  if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1425
1713
  return { acquired: false, attached: false, blocked: true };
1426
1714
  const incarnation = input.pidIncarnation(state.pid);
@@ -1522,12 +1810,7 @@ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
1522
1810
  return generation === undefined || generation === 3;
1523
1811
  }
1524
1812
 
1525
- /**
1526
- * A predecessor is reloadable only when it has the complete modern ownership
1527
- * proof. Generation is absent from the first fully-provenanced modern records,
1528
- * so it is an incompatible predecessor too; parent-format records remain
1529
- * excluded because they lack this ownership proof.
1530
- */
1813
+ /** True when a modern owner carries complete ready-state provenance. */
1531
1814
  function hasFullModernOwnerProvenance(
1532
1815
  state: DaemonState | undefined,
1533
1816
  pidIncarnation?: (pid: number) => string | undefined,
@@ -1542,19 +1825,6 @@ function hasFullModernOwnerProvenance(
1542
1825
  );
1543
1826
  }
1544
1827
 
1545
- function isFullModernPredecessor(
1546
- state: DaemonState | undefined,
1547
- pidIncarnation?: (pid: number) => string | undefined,
1548
- ): boolean {
1549
- return Boolean(
1550
- hasFullModernOwnerProvenance(state, pidIncarnation) &&
1551
- (state?.generation === undefined ||
1552
- (Number.isSafeInteger(state?.generation) &&
1553
- (state?.generation as number) > 0 &&
1554
- (state?.generation as number) < DAEMON_GENERATION)),
1555
- );
1556
- }
1557
-
1558
1828
  /**
1559
1829
  * Classifies a physically live daemon record before any replacement action.
1560
1830
  *
@@ -1628,14 +1898,17 @@ export function isFreshLiveOwner(input: {
1628
1898
  chatId: string;
1629
1899
  pidAlive: (pid: number) => boolean;
1630
1900
  pidIncarnation?: (pid: number) => string | undefined;
1901
+ effectiveHeartbeatAt?: number;
1631
1902
  }): boolean {
1632
1903
  const { state } = input;
1904
+ const heartbeatAt = Object.hasOwn(input, "effectiveHeartbeatAt") ? input.effectiveHeartbeatAt : state?.heartbeatAt;
1633
1905
  return Boolean(
1634
1906
  state &&
1907
+ heartbeatAt !== undefined &&
1635
1908
  hasSafeDaemonStateShape(state) &&
1636
1909
  state.stoppedAt === undefined &&
1637
1910
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1638
- input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
1911
+ input.now - heartbeatAt <= HEARTBEAT_TTL_MS &&
1639
1912
  input.pidAlive(state.pid) &&
1640
1913
  ownerProvenanceMatches(state, input.pidIncarnation),
1641
1914
  );
@@ -1649,6 +1922,7 @@ export function isCurrentCompatibleOwner(input: {
1649
1922
  chatId: string;
1650
1923
  pidAlive: (pid: number) => boolean;
1651
1924
  pidIncarnation?: (pid: number) => string | undefined;
1925
+ effectiveHeartbeatAt?: number;
1652
1926
  }): boolean {
1653
1927
  const state = input.state;
1654
1928
  return Boolean(
@@ -1656,8 +1930,7 @@ export function isCurrentCompatibleOwner(input: {
1656
1930
  state?.ownershipPhase === "ready" &&
1657
1931
  typeof state.acquisitionId === "string" &&
1658
1932
  state.acquisitionId.length > 0 &&
1659
- Number.isSafeInteger(state.generation) &&
1660
- (state.generation as number) >= DAEMON_GENERATION,
1933
+ (state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH,
1661
1934
  );
1662
1935
  }
1663
1936
 
@@ -1701,14 +1974,14 @@ export async function acquireDaemonOwnership(input: {
1701
1974
  input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1702
1975
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
1703
1976
 
1704
- // A fresh, identity-matching live owner running an OLDER generation than this
1705
- // build cannot serve our newer wire frames; signal a reload instead of a
1706
- // silent attach. Newer/equal generations attach as before (no downgrade).
1977
+ // Generation is inventory-only: a lower servingEpoch triggers convergence,
1978
+ // while same-epoch cross-generation owners attach.
1707
1979
  const attachDecision = (
1708
- state: DaemonState | undefined,
1980
+ snapshot: OwnerFreshnessSnapshot,
1709
1981
  ):
1710
1982
  | { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
1711
1983
  | undefined => {
1984
+ const state = snapshot.state;
1712
1985
  if (state && !hasSafeDaemonStateShape(state)) {
1713
1986
  const malformed = state as Partial<DaemonState>;
1714
1987
  if (
@@ -1741,6 +2014,7 @@ export async function acquireDaemonOwnership(input: {
1741
2014
  chatId: input.chatId,
1742
2015
  pidAlive,
1743
2016
  pidIncarnation,
2017
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
1744
2018
  })
1745
2019
  )
1746
2020
  return { acquired: false, attached: true };
@@ -1755,8 +2029,17 @@ export async function acquireDaemonOwnership(input: {
1755
2029
  chatId: input.chatId,
1756
2030
  pidAlive,
1757
2031
  pidIncarnation,
2032
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
1758
2033
  }) &&
1759
- (state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
2034
+ (state.version !== DAEMON_VERSION ||
2035
+ ((state.servingEpoch === undefined ? 1 : state.servingEpoch) < SERVING_EPOCH &&
2036
+ isSignalableMatchingOwner({
2037
+ state,
2038
+ tokenFingerprint: input.tokenFingerprint,
2039
+ chatId: input.chatId,
2040
+ pidAlive,
2041
+ pidIncarnation,
2042
+ })))
1760
2043
  )
1761
2044
  return { acquired: false, attached: false, reloadRequired: true };
1762
2045
  return { acquired: false, attached: false, provisional: true };
@@ -1800,7 +2083,8 @@ export async function acquireDaemonOwnership(input: {
1800
2083
  const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1801
2084
  if (!transition) return { acquired: false, attached: false, provisional: true };
1802
2085
  try {
1803
- const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
2086
+ const recheckedSnapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
2087
+ const rechecked = recheckedSnapshot.state;
1804
2088
  const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1805
2089
  const recheckedForeignOwner = classifyForeignLiveOwner({
1806
2090
  state: rechecked,
@@ -1826,7 +2110,7 @@ export async function acquireDaemonOwnership(input: {
1826
2110
  })
1827
2111
  )
1828
2112
  return { acquired: false, attached: false, blocked: true };
1829
- const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
2113
+ const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(recheckedSnapshot);
1830
2114
  if (
1831
2115
  recheckedDecision &&
1832
2116
  (recheckedDecision.attached ||
@@ -1923,6 +2207,7 @@ export async function acquireDaemonOwnership(input: {
1923
2207
  roots,
1924
2208
  version: DAEMON_VERSION,
1925
2209
  generation: DAEMON_GENERATION,
2210
+ servingEpoch: SERVING_EPOCH,
1926
2211
  } satisfies DaemonState);
1927
2212
  if (
1928
2213
  !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
@@ -2022,17 +2307,20 @@ export async function renewDaemonHeartbeat(input: {
2022
2307
  })))
2023
2308
  )
2024
2309
  return false;
2310
+ const heartbeatAt = (input.now ?? Date.now)();
2311
+ const boundState: DaemonState = {
2312
+ ...state,
2313
+ // Preserve the source launcher PID so concurrent ensures can recognize
2314
+ // this bound child as its successor while it is still provisional.
2315
+ launcherPid: state.pid,
2316
+ pid,
2317
+ incarnation,
2318
+ ownershipPhase: "provisional",
2319
+ heartbeatAt,
2320
+ servingEpoch: SERVING_EPOCH,
2321
+ };
2025
2322
  try {
2026
- await writeJsonAtomic(fsImpl, paths.state, {
2027
- ...state,
2028
- // Preserve the source launcher PID so concurrent ensures can recognize
2029
- // this ready PID as its child rather than excluding it as the launcher.
2030
- launcherPid: state.pid,
2031
- pid,
2032
- incarnation,
2033
- ownershipPhase: "ready",
2034
- heartbeatAt: (input.now ?? Date.now)(),
2035
- });
2323
+ await writeJsonAtomic(fsImpl, paths.state, boundState);
2036
2324
  } catch {
2037
2325
  if (expectedLock && reboundLock)
2038
2326
  await rollbackOwnershipLockRebind({
@@ -2046,7 +2334,49 @@ export async function renewDaemonHeartbeat(input: {
2046
2334
  });
2047
2335
  return false;
2048
2336
  }
2049
- return true;
2337
+ const retireBoundOwnership = async (): Promise<void> => {
2338
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2339
+ if (!ownershipLockMatchesState(lock, boundState)) return;
2340
+ await writeJsonAtomic(fsImpl, paths.state, {
2341
+ ...boundState,
2342
+ ownershipPhase: "retired",
2343
+ stoppedAt: (input.now ?? Date.now)(),
2344
+ }).catch(() => undefined);
2345
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2346
+ await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
2347
+ };
2348
+ // The durable sidecar must exist and match the rebound ownership lock before
2349
+ // any observer can see ownershipPhase:"ready". A provisional state may be
2350
+ // fresh, but readiness waiters categorically refuse to attach to it.
2351
+ let sidecarRenewed = false;
2352
+ try {
2353
+ sidecarRenewed = await renewOwnerHeartbeatSidecar({
2354
+ settings: input.settings,
2355
+ ownerId: input.ownerId,
2356
+ acquisitionId,
2357
+ fs: fsImpl,
2358
+ now: () => heartbeatAt,
2359
+ pid,
2360
+ pidIncarnation: input.pidIncarnation,
2361
+ });
2362
+ } catch {
2363
+ sidecarRenewed = false;
2364
+ }
2365
+ if (!sidecarRenewed) {
2366
+ logger.warn(
2367
+ "notifications: Telegram daemon initial heartbeat sidecar proof failed; retiring provisional ownership",
2368
+ );
2369
+ await retireBoundOwnership();
2370
+ return false;
2371
+ }
2372
+ try {
2373
+ await writeJsonAtomic(fsImpl, paths.state, { ...boundState, ownershipPhase: "ready" });
2374
+ return true;
2375
+ } catch {
2376
+ logger.warn("notifications: Telegram daemon ready publication failed after sidecar proof; retiring ownership");
2377
+ await retireBoundOwnership();
2378
+ return false;
2379
+ }
2050
2380
  } finally {
2051
2381
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2052
2382
  }
@@ -2225,7 +2555,7 @@ async function bindProvisionalDaemonPid(input: {
2225
2555
  }
2226
2556
  }
2227
2557
 
2228
- /** Wait for a matching current-generation daemon to publish a ready state. */
2558
+ /** Wait for a matching compatible owner to publish a ready state. Readiness is serving-epoch gated (same-epoch cross-generation owners attach, per isCurrentCompatibleOwner); generation is inventory-only here. Mutation paths still require exact current-generation equality. */
2229
2559
  export async function waitForTelegramDaemonReady(input: {
2230
2560
  settings: Settings;
2231
2561
  ownerId?: string;
@@ -2251,12 +2581,13 @@ export async function waitForTelegramDaemonReady(input: {
2251
2581
  const deadline = now() + timeoutMs;
2252
2582
  const maxPolls = Math.ceil(timeoutMs / waitStepMs);
2253
2583
  for (let poll = 0; poll <= maxPolls; poll++) {
2254
- const state = await readDaemonState(input.settings, input.fs);
2584
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
2585
+ const state = snapshot.state;
2255
2586
  if (
2256
2587
  (!input.ownerId || state?.ownerId === input.ownerId) &&
2257
2588
  (!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
2258
2589
  state?.ownershipPhase === "ready" &&
2259
- state.generation === DAEMON_GENERATION &&
2590
+ Number.isSafeInteger(state.generation) &&
2260
2591
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
2261
2592
  ownerProvenanceMatches(state, pidIncarnation) &&
2262
2593
  pidAlive(state.pid) &&
@@ -2270,6 +2601,7 @@ export async function waitForTelegramDaemonReady(input: {
2270
2601
  chatId: input.chatId,
2271
2602
  pidAlive,
2272
2603
  pidIncarnation,
2604
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2273
2605
  })
2274
2606
  )
2275
2607
  return true;
@@ -2339,7 +2671,8 @@ export async function confirmTelegramDaemonSpawn(input: {
2339
2671
  sleep: input.sleep,
2340
2672
  });
2341
2673
  if (retired) return false;
2342
- const state = await readDaemonState(input.settings, input.fs);
2674
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: input.fs });
2675
+ const state = snapshot.state;
2343
2676
  if (
2344
2677
  hasExactChildPid &&
2345
2678
  isCurrentCompatibleOwner({
@@ -2349,6 +2682,7 @@ export async function confirmTelegramDaemonSpawn(input: {
2349
2682
  chatId: input.chatId,
2350
2683
  pidAlive: input.pidAlive ?? defaultPidAlive,
2351
2684
  pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
2685
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2352
2686
  }) &&
2353
2687
  (state?.ownerId !== input.spawned.acquisition.ownerId ||
2354
2688
  state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
@@ -2812,8 +3146,15 @@ export async function reclaimDeadDaemonOwner(input: {
2812
3146
  * Ensure a configured daemon owns this session root, preserving ownership safety
2813
3147
  * while exposing whether a #2028 generation handoff was required.
2814
3148
  */
2815
- export async function ensureTelegramDaemonRunningDetailed(
2816
- input: { settings: Settings; cwd: string; sessionId: string },
3149
+ async function ensureTelegramDaemonRunningDetailedOnce(
3150
+ input: {
3151
+ settings: Settings;
3152
+ cwd: string;
3153
+ sessionId: string;
3154
+ registerRoot?: boolean;
3155
+ /** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
3156
+ onRegistered?: (registration: RegisterNotificationRootResult) => void;
3157
+ },
2817
3158
  deps: TelegramDaemonDeps = {},
2818
3159
  ): Promise<EnsureTelegramDaemonDetailedResult> {
2819
3160
  const cfg = getNotificationConfig(input.settings);
@@ -2919,7 +3260,12 @@ export async function ensureTelegramDaemonRunningDetailed(
2919
3260
  }
2920
3261
  if (spawned.result === "attached" && spawned.reloadRequired) {
2921
3262
  const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2922
- await registerNotificationRoot({ ...input, fs: deps.fs });
3263
+ let registeredToken: string | undefined;
3264
+ if (input.registerRoot !== false) {
3265
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3266
+ registeredToken = registered.token;
3267
+ input.onRegistered?.(registered);
3268
+ }
2923
3269
  const fsImpl = deps.fs ?? nodeFs;
2924
3270
  const now = deps.now ?? Date.now;
2925
3271
  const pidAlive = deps.pidAlive ?? defaultPidAlive;
@@ -2958,22 +3304,28 @@ export async function ensureTelegramDaemonRunningDetailed(
2958
3304
  // Iteration-capped so a frozen `now` cannot spin forever.
2959
3305
  const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
2960
3306
  for (let waits = 0; waits <= maxWaits; waits++) {
2961
- const state = await readDaemonState(input.settings, fsImpl);
3307
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: fsImpl });
3308
+ const state = snapshot.state;
2962
3309
  const t = now();
2963
3310
  if (
2964
- isFreshLiveOwner({
3311
+ isCurrentCompatibleOwner({
2965
3312
  state,
2966
3313
  now: t,
2967
3314
  tokenFingerprint: fp,
2968
3315
  chatId: cfg.chatId,
2969
3316
  pidAlive,
2970
3317
  pidIncarnation,
3318
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
2971
3319
  })
2972
3320
  )
2973
3321
  return "attached" as const;
2974
3322
  if (t >= deadline) break;
2975
3323
  await sleep(waitStepMs);
2976
3324
  }
3325
+ // Keep the reservation after a failed reload so concurrent ensures do not
3326
+ // repeatedly signal the same incompatible predecessor. Surface the failed
3327
+ // convergence; the next ensure after cooldown expiry retries the reload.
3328
+ return "blocked_identity" as const;
2977
3329
  }
2978
3330
  await writeJsonAtomic(fsImpl, reloadAttemptPath, {
2979
3331
  lastReloadAt: reloadNow,
@@ -2991,11 +3343,23 @@ export async function ensureTelegramDaemonRunningDetailed(
2991
3343
  }),
2992
3344
  );
2993
3345
  if (reloadResult === "attached") return "attached";
3346
+ if (reloadResult === "blocked_identity") {
3347
+ await restoreNotificationRootRegistration({
3348
+ settings: input.settings,
3349
+ sessionId: input.sessionId,
3350
+ registeredRoot: root,
3351
+ registeredToken,
3352
+ previous,
3353
+ fs: deps.fs,
3354
+ });
3355
+ return "blocked_identity";
3356
+ }
2994
3357
  if (reloadResult !== "reloaded") {
2995
3358
  await restoreNotificationRootRegistration({
2996
3359
  settings: input.settings,
2997
3360
  sessionId: input.sessionId,
2998
3361
  registeredRoot: root,
3362
+ registeredToken,
2999
3363
  previous,
3000
3364
  fs: deps.fs,
3001
3365
  });
@@ -3004,7 +3368,10 @@ export async function ensureTelegramDaemonRunningDetailed(
3004
3368
  return "reloaded";
3005
3369
  }
3006
3370
  if (spawned.result !== "owner_spawned") {
3007
- await registerNotificationRoot({ ...input, fs: deps.fs });
3371
+ if (input.registerRoot !== false) {
3372
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3373
+ input.onRegistered?.(registered);
3374
+ }
3008
3375
  return "attached";
3009
3376
  }
3010
3377
  if (
@@ -3023,12 +3390,54 @@ export async function ensureTelegramDaemonRunningDetailed(
3023
3390
  timeoutMs: deps.readinessTimeoutMs,
3024
3391
  })
3025
3392
  ) {
3026
- await registerNotificationRoot({ ...input, fs: deps.fs });
3393
+ if (input.registerRoot !== false) {
3394
+ const registered = await registerNotificationRoot({ ...input, fs: deps.fs });
3395
+ input.onRegistered?.(registered);
3396
+ }
3027
3397
  return "spawned";
3028
3398
  }
3029
3399
  throw new Error("Telegram daemon did not become ready after spawning");
3030
3400
  }
3031
3401
 
3402
+ /**
3403
+ * Ensure a configured daemon owns this session root, preserving ownership safety
3404
+ * while exposing whether a #2028 generation handoff was required.
3405
+ */
3406
+ export async function ensureTelegramDaemonRunningDetailed(
3407
+ input: {
3408
+ settings: Settings;
3409
+ cwd: string;
3410
+ sessionId: string;
3411
+ registerRoot?: boolean;
3412
+ /** Receives each registration this ensure operation minted so the caller can fence its own later cleanup. */
3413
+ onRegistered?: (registration: RegisterNotificationRootResult) => void;
3414
+ },
3415
+ deps: TelegramDaemonDeps = {},
3416
+ ): Promise<EnsureTelegramDaemonDetailedResult> {
3417
+ const result = await ensureTelegramDaemonRunningDetailedOnce(input, deps);
3418
+ if (input.registerRoot === false || (result !== "attached" && result !== "spawned")) return result;
3419
+
3420
+ const cfg = getNotificationConfig(input.settings);
3421
+ if (!isTelegramConfigured(cfg)) return "disabled";
3422
+ const ownerIsCurrent = async (): Promise<boolean> => {
3423
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: input.settings, fs: deps.fs });
3424
+ return isCurrentCompatibleOwner({
3425
+ state: snapshot.state,
3426
+ now: (deps.now ?? Date.now)(),
3427
+ tokenFingerprint: tokenFingerprint(cfg.botToken),
3428
+ chatId: cfg.chatId,
3429
+ pidAlive: deps.pidAlive ?? defaultPidAlive,
3430
+ pidIncarnation: deps.pidIncarnation ?? defaultPidIncarnation,
3431
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
3432
+ });
3433
+ };
3434
+ if (await ownerIsCurrent()) return result;
3435
+
3436
+ const converged = await ensureTelegramDaemonRunningDetailedOnce({ ...input, registerRoot: false }, deps);
3437
+ if (converged === "disabled" || converged === "blocked_identity") return converged;
3438
+ return (await ownerIsCurrent()) ? converged : "blocked_identity";
3439
+ }
3440
+
3032
3441
  /**
3033
3442
  * Legacy compatibility mapping for callers that only distinguish ownership from
3034
3443
  * attachment. New orchestration should use {@link ensureTelegramDaemonRunningDetailed}.
@@ -3196,7 +3605,7 @@ export type TelegramPollResult =
3196
3605
  export interface TelegramUpdatePollerOptions {
3197
3606
  botApi: BotApi;
3198
3607
  runtime: NotificationOperatorRuntime;
3199
- backoff: OperatorBackoffPolicy;
3608
+ backoff: { next(): number; reset(): void };
3200
3609
  processUpdate: (update: unknown) => Promise<TelegramUpdateOutcome>;
3201
3610
  health?: TelegramPollHealth;
3202
3611
  }
@@ -3258,6 +3667,20 @@ export class TelegramPollHealth {
3258
3667
  }
3259
3668
  }
3260
3669
 
3670
+ class JitteredPollConflictBackoff {
3671
+ #currentMs = 0;
3672
+
3673
+ next(): number {
3674
+ if (this.#currentMs === 0) this.#currentMs = POLL_BACKOFF_MS;
3675
+ else this.#currentMs = Math.min(this.#currentMs * (1.5 + Math.random() * 0.5), 10_000);
3676
+ return this.#currentMs;
3677
+ }
3678
+
3679
+ reset(): void {
3680
+ this.#currentMs = 0;
3681
+ }
3682
+ }
3683
+
3261
3684
  /** Owns getUpdates offset, conflict backoff, and per-update error isolation. */
3262
3685
  export class TelegramUpdatePoller {
3263
3686
  #offset = 0;
@@ -3423,7 +3846,7 @@ export interface TelegramDaemonOptions {
3423
3846
  rich?: { enabled: boolean };
3424
3847
  /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
3425
3848
  richDraft?: { enabled: boolean };
3426
- /** Tool start/completion messages (enabled by default). */
3849
+ /** Tool start/completion messages (off by default; explicit opt-in only). */
3427
3850
  toolActivity?: { enabled: boolean };
3428
3851
  /**
3429
3852
  * Per-session Telegram forum-topic naming. `nameTemplate` supports the
@@ -3452,6 +3875,8 @@ interface SessionSocket {
3452
3875
  /** True once the server advertised the `client_ping_pong` capability. */
3453
3876
  capable: boolean;
3454
3877
  ephemeralCapable: boolean;
3878
+ /** Tool activity contract advertised by the connected host. */
3879
+ toolActivityCapability?: "v1" | "v2";
3455
3880
  /** Timestamp (via opts.now) of the last received pong; seeds the TTL window. */
3456
3881
  lastPongAt: number;
3457
3882
  /** Nonce of the most recent in-flight ping, if any. */
@@ -3502,6 +3927,19 @@ interface ToolActivityOwner {
3502
3927
  session: SessionSocket;
3503
3928
  phase: "started" | "terminal";
3504
3929
  policyEpoch?: number;
3930
+ summaryFreeSend?: ThreadedSend;
3931
+ }
3932
+ type LegacyToolStartOutcome = "visible" | "failed" | "cancelled" | "terminal";
3933
+ interface LegacyToolStartSettlement {
3934
+ key: string;
3935
+ owner: ToolActivityOwner;
3936
+ policyEpoch: number;
3937
+ phase: "admitted" | "pending_identity" | "queued" | "dispatching" | LegacyToolStartOutcome;
3938
+ itemId?: string;
3939
+ settled: Promise<LegacyToolStartOutcome>;
3940
+ resolve: (outcome: LegacyToolStartOutcome) => void;
3941
+ settledOutcome?: LegacyToolStartOutcome;
3942
+ terminalClaimed?: boolean;
3505
3943
  }
3506
3944
 
3507
3945
  interface PendingThreadedFrame {
@@ -3518,6 +3956,35 @@ type SelectedAckOutcome =
3518
3956
  | { status: "unknown"; reason: "transport_ambiguous" | "shutdown" };
3519
3957
  type BtwQueuedDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "stale" | "partial_accepted";
3520
3958
 
3959
+ type BotApiCallOutcome =
3960
+ | { kind: "accepted" }
3961
+ | { kind: "rejected" }
3962
+ | { kind: "retryable"; retryAfterMs: number }
3963
+ | { kind: "unknown" };
3964
+
3965
+ interface BotApiCallResult {
3966
+ response: unknown;
3967
+ outcome: BotApiCallOutcome;
3968
+ }
3969
+
3970
+ function classifyBotApiCallOutcome(response: unknown, cooldownSuppressed = false): BotApiCallOutcome {
3971
+ if (cooldownSuppressed) return { kind: "retryable", retryAfterMs: 0 };
3972
+ if (!response || typeof response !== "object") return { kind: "unknown" };
3973
+ const body = response as {
3974
+ ok?: unknown;
3975
+ error_code?: unknown;
3976
+ parameters?: { retry_after?: unknown };
3977
+ };
3978
+ if (body.ok === false && body.error_code === 429) {
3979
+ const retryAfter = body.parameters?.retry_after;
3980
+ const seconds = typeof retryAfter === "number" && Number.isFinite(retryAfter) ? retryAfter : 30;
3981
+ return { kind: "retryable", retryAfterMs: Math.min(3600, Math.max(1, seconds)) * 1_000 };
3982
+ }
3983
+ if (body.ok === true) return { kind: "accepted" };
3984
+ if (body.ok === false) return { kind: "rejected" };
3985
+ return { kind: "unknown" };
3986
+ }
3987
+
3521
3988
  interface BtwQueuedDelivery {
3522
3989
  pending: PendingBtwTurn;
3523
3990
  body: Record<string, unknown>;
@@ -3548,6 +4015,8 @@ interface TelegramQueuePayload {
3548
4015
  selectedAck?: SelectedAckQueueItem;
3549
4016
  btwDelivery?: BtwQueuedDelivery;
3550
4017
  toolActivity?: ToolActivityOwner;
4018
+ /** Exact admitted legacy-v1 start authorized for this terminal delivery. */
4019
+ legacyToolStart?: LegacyToolStartSettlement;
3551
4020
  }
3552
4021
 
3553
4022
  interface PendingBtwTurn {
@@ -3645,6 +4114,9 @@ export class TelegramNotificationDaemon {
3645
4114
  /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
3646
4115
  private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
3647
4116
  private readonly revokedToolEndpoints = new Set<string>();
4117
+ /** Exact settlement of each admitted legacy-v1 start; retained only while visible. */
4118
+ private readonly legacyToolStarts = new Map<string, LegacyToolStartSettlement>();
4119
+ private nextLegacyToolStartId = 1;
3648
4120
  private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
3649
4121
  private toolTerminalizationChain: Promise<void> = Promise.resolve();
3650
4122
  private toolActivityPolicyEpoch = 0;
@@ -3658,7 +4130,7 @@ export class TelegramNotificationDaemon {
3658
4130
 
3659
4131
  private readonly runtime: NotificationOperatorRuntime;
3660
4132
  private readonly sessionRouter: OperatorEventRouter<SessionSocket>;
3661
- private readonly pollConflictBackoff = new OperatorBackoffPolicy({ initialMs: 500, maxMs: 5_000 });
4133
+ private readonly pollConflictBackoff = new JitteredPollConflictBackoff();
3662
4134
  private readonly loopBackoff = new OperatorBackoffPolicy({ initialMs: 250, maxMs: 4_000 });
3663
4135
  private running = false;
3664
4136
  /** Once set, a concurrent startup await can never restore a running daemon. */
@@ -3698,6 +4170,9 @@ export class TelegramNotificationDaemon {
3698
4170
  private readonly pool: RateLimitPool<TelegramQueuePayload>;
3699
4171
  private readonly poller: TelegramUpdatePoller;
3700
4172
  private readonly dispatchState = new TelegramEventDispatchState();
4173
+ /** Bot-wide flood-control window; inbound polling remains eligible during it. */
4174
+ private botCooldownUntil = 0;
4175
+ private warnedBotCooldownUntil = 0;
3701
4176
  /** Original markdown of rich messages we sent (chat+message_id), for restoring reply context on inbound replies. */
3702
4177
  private readonly replyStore: ReplySentStore;
3703
4178
  /** Per-session debounce + monotonic draft-id state for opt-in draft streaming. */
@@ -4116,8 +4591,8 @@ export class TelegramNotificationDaemon {
4116
4591
 
4117
4592
  private async refreshBotIdentity(): Promise<void> {
4118
4593
  try {
4119
- const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } };
4120
- const username = response.result?.username;
4594
+ const response = (await this.botApi.call("getMe", {})) as { result?: { username?: unknown } } | undefined;
4595
+ const username = response?.result?.username;
4121
4596
  this.botUsername =
4122
4597
  typeof username === "string" && username.trim() ? username.trim().replace(/^@/, "") : undefined;
4123
4598
  } catch {
@@ -4130,6 +4605,47 @@ export class TelegramNotificationDaemon {
4130
4605
  return formatLifecycleOutcome(r, verb);
4131
4606
  }
4132
4607
 
4608
+ private async callBotApi(
4609
+ rawBotApi: BotApi,
4610
+ method: string,
4611
+ body: unknown,
4612
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4613
+ ): Promise<BotApiCallResult> {
4614
+ const now = this.opts.now?.() ?? Date.now();
4615
+ if (method !== "getUpdates" && now < this.botCooldownUntil) {
4616
+ if (this.warnedBotCooldownUntil !== this.botCooldownUntil) {
4617
+ this.warnedBotCooldownUntil = this.botCooldownUntil;
4618
+ logger.warn("notifications: Telegram Bot API flood-control cooldown suppressing outbound calls");
4619
+ }
4620
+ const outcome = classifyBotApiCallOutcome(undefined, true);
4621
+ return { response: undefined, outcome };
4622
+ }
4623
+ const response = await this.effects.call(rawBotApi, method, body, callOpts);
4624
+ const outcome = classifyBotApiCallOutcome(response);
4625
+ if (outcome.kind === "retryable")
4626
+ this.botCooldownUntil = Math.max(
4627
+ this.botCooldownUntil,
4628
+ (this.opts.now?.() ?? Date.now()) + outcome.retryAfterMs,
4629
+ );
4630
+ return { response, outcome };
4631
+ }
4632
+
4633
+ private readonly callBotApiClassified: (
4634
+ method: string,
4635
+ body: unknown,
4636
+ callOpts?: { signal?: AbortSignal; noRetry?: boolean },
4637
+ ) => Promise<BotApiCallResult>;
4638
+
4639
+ private botApiWithOutcomeCollector(outcomes: BotApiCallOutcome[]): BotApi {
4640
+ return {
4641
+ call: async (method, body, callOpts) => {
4642
+ const result = await this.callBotApiClassified(method, body, callOpts);
4643
+ outcomes.push(result.outcome);
4644
+ return result.response;
4645
+ },
4646
+ };
4647
+ }
4648
+
4133
4649
  constructor(private readonly opts: TelegramDaemonOptions) {
4134
4650
  this.fsImpl = opts.fs ?? nodeFs;
4135
4651
  this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs });
@@ -4142,8 +4658,9 @@ export class TelegramNotificationDaemon {
4142
4658
  fetchImpl: opts.fetchImpl,
4143
4659
  setTimeoutImpl: opts.setTimeoutImpl,
4144
4660
  });
4661
+ this.callBotApiClassified = (method, body, callOpts) => this.callBotApi(rawBotApi, method, body, callOpts);
4145
4662
  this.botApi = {
4146
- call: (method, body, callOpts) => this.effects.call(rawBotApi, method, body, callOpts),
4663
+ call: async (method, body, callOpts) => (await this.callBotApiClassified(method, body, callOpts)).response,
4147
4664
  };
4148
4665
  this.runtime = new NotificationOperatorRuntime({
4149
4666
  now: opts.now,
@@ -4169,6 +4686,10 @@ export class TelegramNotificationDaemon {
4169
4686
  matches: msg => msg.type === "hello",
4170
4687
  handle: (session, msg) => {
4171
4688
  const caps = Array.isArray(msg.capabilities) ? msg.capabilities : [];
4689
+ const previousToolActivityCapability = session.toolActivityCapability;
4690
+ session.toolActivityCapability = negotiateToolActivityCapability(previousToolActivityCapability, caps);
4691
+ if (previousToolActivityCapability === "v1" && session.toolActivityCapability === "v2")
4692
+ this.cleanLegacyToolStartsForCapabilityUpgrade(session);
4172
4693
  if (caps.includes("ephemeral_turn_v1")) session.ephemeralCapable = true;
4173
4694
  if (caps.includes(CLIENT_PING_PONG_CAPABILITY)) {
4174
4695
  session.capable = true;
@@ -4701,6 +5222,7 @@ export class TelegramNotificationDaemon {
4701
5222
  ASK_CONTROLS_CAPABILITY,
4702
5223
  ASK_SELECTED_ACK_CAPABILITY,
4703
5224
  TOOL_ACTIVITY_CAPABILITY,
5225
+ LEGACY_TOOL_ACTIVITY_CAPABILITY,
4704
5226
  "ephemeral_turn_v1",
4705
5227
  ],
4706
5228
  }),
@@ -4794,12 +5316,12 @@ export class TelegramNotificationDaemon {
4794
5316
  for (const item of claimedItems) {
4795
5317
  const { messageId, owner } = item;
4796
5318
  const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
4797
- const send = renderThreadedFrame({
5319
+ const send = this.renderThreadedFrame({
4798
5320
  type: "tool_activity",
4799
5321
  sessionId: owner.sessionId,
4800
5322
  toolCallId: owner.toolCallId,
4801
5323
  toolName: owner.toolName,
4802
- phase: "unknown",
5324
+ phase: "cancelled",
4803
5325
  });
4804
5326
  if (!send?.text) continue;
4805
5327
  let failure: unknown;
@@ -4857,6 +5379,9 @@ export class TelegramNotificationDaemon {
4857
5379
  this.toolActivityOwners.delete(key);
4858
5380
  const messageId = this.liveMessages.get(key);
4859
5381
  this.liveMessages.delete(key);
5382
+ const legacyStart = this.legacyToolStarts.get(key);
5383
+ if (legacyStart !== undefined && legacyStart.owner === owner)
5384
+ this.settleLegacyToolStart(legacyStart, "terminal");
4860
5385
  if (messageId !== undefined) {
4861
5386
  const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
4862
5387
  claimedByKey.set(backlogKey, { messageId, owner });
@@ -4884,6 +5409,7 @@ export class TelegramNotificationDaemon {
4884
5409
  this.toolActivityStopping = true;
4885
5410
  this.toolActivityPolicyEpoch++;
4886
5411
  this.toolShutdownBarrier = (async () => {
5412
+ await this.cancelLegacyToolStartsForPolicyTransition();
4887
5413
  await this.flushChain;
4888
5414
  const failures: Error[] = [];
4889
5415
  if (this.toolActivityAmbiguous) {
@@ -4908,6 +5434,7 @@ export class TelegramNotificationDaemon {
4908
5434
  if (session.recoveryLease) session.recoveryLease = { ...session.recoveryLease, state: "rejected" };
4909
5435
  const isCurrentSession = this.sessions.get(session.sessionId) === session;
4910
5436
  if (isCurrentSession) this.droppedSessions.add(session);
5437
+ if (isCurrentSession) this.cancelLegacyToolStartsForSession(session);
4911
5438
  if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
4912
5439
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
4913
5440
  if (session.pingTimer) {
@@ -5438,7 +5965,7 @@ export class TelegramNotificationDaemon {
5438
5965
  typeof msg.sessionId !== "string" ||
5439
5966
  !msg.sessionId.trim() ||
5440
5967
  msg.sessionId === this.#logicalSessionId(session) ||
5441
- (msg.type !== "config_update" && !renderThreadedFrame(msg))
5968
+ (msg.type !== "config_update" && !this.renderThreadedFrame(msg))
5442
5969
  )
5443
5970
  return;
5444
5971
  if (!session.logicalSessionIdTrusted) return;
@@ -5700,12 +6227,151 @@ export class TelegramNotificationDaemon {
5700
6227
  return undefined;
5701
6228
  }
5702
6229
 
6230
+ private admitLegacyToolStart(owner: ToolActivityOwner): LegacyToolStartSettlement | undefined {
6231
+ if (owner.session.toolActivityCapability !== "v1") return undefined;
6232
+ const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
6233
+ if (this.legacyToolStarts.has(key)) return undefined;
6234
+ const deferred = Promise.withResolvers<LegacyToolStartOutcome>();
6235
+ const state: LegacyToolStartSettlement = {
6236
+ key,
6237
+ owner,
6238
+ policyEpoch: owner.policyEpoch ?? this.toolActivityPolicyEpoch,
6239
+ phase: "admitted",
6240
+ settled: deferred.promise,
6241
+ resolve: deferred.resolve,
6242
+ };
6243
+ this.legacyToolStarts.set(key, state);
6244
+ return state;
6245
+ }
6246
+ private legacyToolStartForTerminal(owner: ToolActivityOwner): LegacyToolStartSettlement | undefined {
6247
+ if (owner.phase !== "terminal") return undefined;
6248
+ const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
6249
+ const state = this.legacyToolStarts.get(key);
6250
+ if (
6251
+ state === undefined ||
6252
+ state.key !== key ||
6253
+ state.owner.session !== owner.session ||
6254
+ state.owner.endpointDigest !== owner.endpointDigest ||
6255
+ state.owner.toolName !== owner.toolName ||
6256
+ state.policyEpoch !== owner.policyEpoch
6257
+ )
6258
+ return undefined;
6259
+ return state;
6260
+ }
6261
+
6262
+ private settleLegacyToolStart(state: LegacyToolStartSettlement, outcome: LegacyToolStartOutcome): void {
6263
+ if (state.settledOutcome === undefined) {
6264
+ state.settledOutcome = outcome;
6265
+ state.resolve(outcome);
6266
+ }
6267
+ state.phase = outcome;
6268
+ if (outcome !== "visible" && this.legacyToolStarts.get(state.key) === state)
6269
+ this.legacyToolStarts.delete(state.key);
6270
+ if (outcome !== "visible" && !this.liveMessages.has(state.key)) {
6271
+ const owner = this.toolActivityOwners.get(state.key);
6272
+ if (owner === state.owner) this.toolActivityOwners.delete(state.key);
6273
+ }
6274
+ }
6275
+
6276
+ private cancelUnsentLegacyToolStart(state: LegacyToolStartSettlement): boolean {
6277
+ if (state.phase !== "admitted" && state.phase !== "pending_identity" && state.phase !== "queued") return false;
6278
+ if (state.itemId) this.pool.removeById(state.itemId);
6279
+ for (const [sessionId, frames] of this.pendingThreadedFrames) {
6280
+ const retained = frames.filter(frame => frame.toolActivity !== state.owner);
6281
+ if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
6282
+ else if (retained.length !== frames.length) this.pendingThreadedFrames.set(sessionId, retained);
6283
+ }
6284
+ this.settleLegacyToolStart(state, "cancelled");
6285
+ return true;
6286
+ }
6287
+
6288
+ private failLegacyToolStart(toolActivity: ToolActivityOwner | undefined): void {
6289
+ if (toolActivity?.phase !== "started") return;
6290
+ const state = this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`);
6291
+ if (state !== undefined && state.owner === toolActivity && state.phase !== "visible")
6292
+ this.settleLegacyToolStart(state, "failed");
6293
+ }
6294
+
6295
+ private settleRejectedLegacyToolSubmission(
6296
+ toolActivity: ToolActivityOwner | undefined,
6297
+ legacyToolStart: LegacyToolStartSettlement | undefined,
6298
+ ): void {
6299
+ if (toolActivity?.phase === "started") {
6300
+ this.failLegacyToolStart(toolActivity);
6301
+ return;
6302
+ }
6303
+ if (
6304
+ toolActivity?.phase !== "terminal" ||
6305
+ legacyToolStart === undefined ||
6306
+ this.legacyToolStarts.get(legacyToolStart.key) !== legacyToolStart
6307
+ )
6308
+ return;
6309
+ this.settleLegacyToolStart(legacyToolStart, "terminal");
6310
+ this.liveMessages.delete(legacyToolStart.key);
6311
+ if (this.toolActivityOwners.get(legacyToolStart.key) === legacyToolStart.owner)
6312
+ this.toolActivityOwners.delete(legacyToolStart.key);
6313
+ }
6314
+
6315
+ private cancelLegacyToolStartsForSession(session: SessionSocket): void {
6316
+ for (const state of [...this.legacyToolStarts.values()]) {
6317
+ if (state.owner.session !== session) continue;
6318
+ if (!this.cancelUnsentLegacyToolStart(state)) this.settleLegacyToolStart(state, "cancelled");
6319
+ }
6320
+ }
6321
+
6322
+ private cleanLegacyToolStartsForCapabilityUpgrade(session: SessionSocket): void {
6323
+ for (const state of [...this.legacyToolStarts.values()]) {
6324
+ if (state.owner.session !== session || state.phase === "dispatching") continue;
6325
+ if (state.phase === "visible") this.settleLegacyToolStart(state, "terminal");
6326
+ else this.cancelUnsentLegacyToolStart(state);
6327
+ }
6328
+ }
6329
+ private cancelLegacyToolStartsForPolicyTransition(): Promise<LegacyToolStartOutcome[]> {
6330
+ const dispatching: Promise<LegacyToolStartOutcome>[] = [];
6331
+ const visibleTerminalizations: Array<{ messageId: number; owner: ToolActivityOwner }> = [];
6332
+ for (const state of [...this.legacyToolStarts.values()]) {
6333
+ if (state.phase === "dispatching")
6334
+ dispatching.push(
6335
+ state.settled.then(outcome => {
6336
+ if (outcome === "visible" && this.legacyToolStarts.get(state.key) === state) {
6337
+ const messageId = this.liveMessages.get(state.key);
6338
+ this.liveMessages.delete(state.key);
6339
+ if (this.toolActivityOwners.get(state.key) === state.owner)
6340
+ this.toolActivityOwners.delete(state.key);
6341
+ this.settleLegacyToolStart(state, "terminal");
6342
+ if (messageId !== undefined) {
6343
+ void this.enqueueToolTerminalization([{ messageId, owner: state.owner }], false);
6344
+ }
6345
+ }
6346
+ return outcome;
6347
+ }),
6348
+ );
6349
+ else if (state.phase === "visible") {
6350
+ const messageId = this.liveMessages.get(state.key);
6351
+ this.liveMessages.delete(state.key);
6352
+ if (this.toolActivityOwners.get(state.key) === state.owner) this.toolActivityOwners.delete(state.key);
6353
+ this.settleLegacyToolStart(state, "terminal");
6354
+ if (messageId !== undefined) {
6355
+ visibleTerminalizations.push({ messageId, owner: state.owner });
6356
+ }
6357
+ } else this.cancelUnsentLegacyToolStart(state);
6358
+ }
6359
+ if (visibleTerminalizations.length > 0) {
6360
+ void this.enqueueToolTerminalization(visibleTerminalizations, false);
6361
+ }
6362
+ return Promise.all(dispatching);
6363
+ }
5703
6364
  private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
5704
6365
  if (msg.type !== "tool_activity") return undefined;
5705
6366
  const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
5706
6367
  const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
5707
6368
  const phase = typeof msg.phase === "string" ? msg.phase : undefined;
5708
- if (!toolCallId || !toolName || !phase) return undefined;
6369
+ if (
6370
+ !toolCallId ||
6371
+ !toolName ||
6372
+ (phase !== "started" && phase !== "completed" && phase !== "failed" && phase !== "cancelled")
6373
+ )
6374
+ return undefined;
5709
6375
  return {
5710
6376
  sessionId: this.#logicalSessionId(session),
5711
6377
  toolCallId,
@@ -5715,6 +6381,30 @@ export class TelegramNotificationDaemon {
5715
6381
  phase: phase === "started" ? "started" : "terminal",
5716
6382
  };
5717
6383
  }
6384
+ private renderThreadedFrame(frame: Record<string, unknown>): ThreadedSend | undefined {
6385
+ if (
6386
+ frame.type === "tool_activity" &&
6387
+ frame.phase !== "started" &&
6388
+ frame.phase !== "completed" &&
6389
+ frame.phase !== "failed" &&
6390
+ frame.phase !== "cancelled"
6391
+ )
6392
+ return undefined;
6393
+ return renderThreadedFrame(frame);
6394
+ }
6395
+ private toolActivityFrameWithoutSummaries(frame: Record<string, unknown>): Record<string, unknown> {
6396
+ const summaryFree = { ...frame };
6397
+ delete summaryFree.argsSummary;
6398
+ delete summaryFree.resultSummary;
6399
+ return summaryFree;
6400
+ }
6401
+ private toolActivitySummariesAreCurrent(toolActivity: ToolActivityOwner): boolean {
6402
+ return (
6403
+ this.opts.toolActivity?.enabled === true &&
6404
+ toolActivity.policyEpoch === this.toolActivityPolicyEpoch &&
6405
+ this.toolActivityAuthorityIsCurrent(toolActivity)
6406
+ );
6407
+ }
5718
6408
 
5719
6409
  private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
5720
6410
  if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
@@ -5723,6 +6413,34 @@ export class TelegramNotificationDaemon {
5723
6413
  if (toolActivity.endpointDigest === undefined) return session === undefined;
5724
6414
  return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
5725
6415
  }
6416
+ private toolActivityDeliveryIsCurrent(toolActivity: ToolActivityOwner): boolean {
6417
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6418
+ const owner = this.toolActivityOwners.get(key);
6419
+ if (owner && owner.session !== toolActivity.session) return false;
6420
+ if (toolActivity.phase === "started") {
6421
+ const legacyStart =
6422
+ toolActivity.session.toolActivityCapability === "v1" ? this.legacyToolStarts.get(key) : undefined;
6423
+ return (
6424
+ (toolActivity.session.toolActivityCapability !== "v1" ||
6425
+ (legacyStart !== undefined &&
6426
+ legacyStart.owner === toolActivity &&
6427
+ legacyStart.phase !== "cancelled" &&
6428
+ legacyStart.phase !== "failed" &&
6429
+ legacyStart.phase !== "terminal")) &&
6430
+ this.toolActivityAuthorityIsCurrent(toolActivity) &&
6431
+ !this.toolActivityStopping &&
6432
+ this.opts.toolActivity?.enabled === true &&
6433
+ toolActivity.policyEpoch === this.toolActivityPolicyEpoch
6434
+ );
6435
+ }
6436
+ if (
6437
+ this.toolActivityAuthorityIsCurrent(toolActivity) &&
6438
+ this.opts.toolActivity?.enabled === true &&
6439
+ toolActivity.policyEpoch === this.toolActivityPolicyEpoch
6440
+ )
6441
+ return true;
6442
+ return this.liveMessages.has(key) && owner?.session === toolActivity.session;
6443
+ }
5726
6444
  private async submitThreadedFrame(
5727
6445
  sessionId: string,
5728
6446
  send: ThreadedSend,
@@ -5730,17 +6448,45 @@ export class TelegramNotificationDaemon {
5730
6448
  toolActivity?: ToolActivityOwner,
5731
6449
  socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
5732
6450
  ): Promise<void> {
5733
- this.submitPool({
6451
+ const legacyStart =
6452
+ toolActivity?.phase === "started"
6453
+ ? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
6454
+ : toolActivity?.phase === "terminal"
6455
+ ? this.legacyToolStartForTerminal(toolActivity)
6456
+ : undefined;
6457
+ if (legacyStart !== undefined && legacyStart.owner === toolActivity) {
6458
+ if (toolActivity!.phase === "started") {
6459
+ legacyStart.phase = "queued";
6460
+ legacyStart.itemId ??= `legacy-tool-start:${this.nextLegacyToolStartId++}`;
6461
+ }
6462
+ }
6463
+ // Terminal frames get a distinct pool item identity so they cannot collide
6464
+ // with an in-flight start for the same legacy settlement.
6465
+ const poolItemId =
6466
+ legacyStart !== undefined && legacyStart.owner === toolActivity
6467
+ ? toolActivity!.phase === "terminal"
6468
+ ? `legacy-tool-terminal:${this.nextLegacyToolStartId++}`
6469
+ : legacyStart.itemId
6470
+ : undefined;
6471
+ const submitted = this.submitPool({
5734
6472
  sessionId,
5735
6473
  lane: send.lane,
5736
6474
  coalesceKey: send.coalesceKey,
6475
+ ...(poolItemId !== undefined ? { itemId: poolItemId } : {}),
5737
6476
  payload: {
5738
6477
  send,
5739
6478
  topicLease,
5740
6479
  ...(socketLease ? { socketLease } : {}),
5741
6480
  ...(toolActivity ? { toolActivity } : {}),
6481
+ ...(legacyStart !== undefined && toolActivity?.phase === "terminal"
6482
+ ? { legacyToolStart: legacyStart }
6483
+ : {}),
5742
6484
  },
5743
6485
  });
6486
+ if (!submitted) {
6487
+ this.settleRejectedLegacyToolSubmission(toolActivity, legacyStart);
6488
+ return;
6489
+ }
5744
6490
  await this.flushPool();
5745
6491
  }
5746
6492
 
@@ -5810,8 +6556,16 @@ export class TelegramNotificationDaemon {
5810
6556
  ): void {
5811
6557
  const logicalSessionId = this.#logicalSessionId(session);
5812
6558
  const socketLease = this.#socketLease(session, logicalSessionId);
5813
- if (!socketLease) return;
6559
+ if (!socketLease) {
6560
+ this.failLegacyToolStart(toolActivity);
6561
+ return;
6562
+ }
5814
6563
  const frames = this.pendingThreadedFrames.get(logicalSessionId) ?? [];
6564
+ const legacyStart =
6565
+ toolActivity?.phase === "started"
6566
+ ? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
6567
+ : undefined;
6568
+ if (legacyStart !== undefined && legacyStart.owner === toolActivity) legacyStart.phase = "pending_identity";
5815
6569
  frames.push({
5816
6570
  send,
5817
6571
  msg,
@@ -5819,7 +6573,10 @@ export class TelegramNotificationDaemon {
5819
6573
  socketLease,
5820
6574
  ...(toolActivity ? { toolActivity } : {}),
5821
6575
  });
5822
- if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
6576
+ if (frames.length > PENDING_TOPIC_FRAME_LIMIT) {
6577
+ const evicted = frames.shift();
6578
+ this.failLegacyToolStart(evicted?.toolActivity);
6579
+ }
5823
6580
  this.pendingThreadedFrames.set(logicalSessionId, frames);
5824
6581
  }
5825
6582
 
@@ -5828,8 +6585,14 @@ export class TelegramNotificationDaemon {
5828
6585
  if (!frames || frames.length === 0) return;
5829
6586
  this.pendingThreadedFrames.delete(sessionId);
5830
6587
  for (const frame of frames) {
5831
- if (frame.logicalSessionId !== sessionId || !this.#leaseTokenAllows(frame.socketLease)) continue;
5832
- if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
6588
+ if (
6589
+ frame.logicalSessionId !== sessionId ||
6590
+ !this.#leaseTokenAllows(frame.socketLease) ||
6591
+ (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true)
6592
+ ) {
6593
+ this.failLegacyToolStart(frame.toolActivity);
6594
+ continue;
6595
+ }
5833
6596
  await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease);
5834
6597
  }
5835
6598
  }
@@ -5868,13 +6631,20 @@ export class TelegramNotificationDaemon {
5868
6631
  let acceptedTopicId: string | undefined;
5869
6632
  let acceptedTopicCompensated = false;
5870
6633
  let acceptedTopicDeleteAttempted = false;
6634
+ let creationSuppressed = false;
5871
6635
  try {
5872
6636
  const rec = await this.topics.getOrCreateTopic(
5873
6637
  sessionId,
5874
6638
  async () => {
5875
6639
  if (capturedCreationLease && !this.#leaseTokenAllows(capturedCreationLease))
5876
6640
  throw new Error("topic authority was revoked during creation");
5877
- const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
6641
+ const res = (await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name })) as
6642
+ | { result?: { message_thread_id?: unknown } }
6643
+ | undefined;
6644
+ if (res === undefined) {
6645
+ creationSuppressed = true;
6646
+ return undefined;
6647
+ }
5878
6648
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
5879
6649
  const response = res as {
5880
6650
  ok?: unknown;
@@ -5958,7 +6728,7 @@ export class TelegramNotificationDaemon {
5958
6728
  }
5959
6729
  return rec.topicId;
5960
6730
  } catch (err) {
5961
- if (err instanceof ThreadedModeCapabilityRefusal) return undefined;
6731
+ if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined;
5962
6732
  if (
5963
6733
  acceptedTopicId &&
5964
6734
  !acceptedTopicCompensated &&
@@ -6445,6 +7215,7 @@ export class TelegramNotificationDaemon {
6445
7215
  this.finishSelectedAck(expiredItem.payload.selectedAck, { status: "failed", reason: "expired" });
6446
7216
  }
6447
7217
  expiredItem.payload.btwDelivery?.finish("not_delivered");
7218
+ this.failLegacyToolStart(expiredItem.payload.toolActivity);
6448
7219
  }
6449
7220
  // Within a batch a finalized frame supersedes any still-queued live frame for
6450
7221
  // the same streamed message (finalized outranks live), so drop the stale live
@@ -6469,17 +7240,18 @@ export class TelegramNotificationDaemon {
6469
7240
  }
6470
7241
  for (const item of batch) {
6471
7242
  const toolActivity = item.payload.toolActivity;
6472
- if (
6473
- toolActivity?.phase === "started" &&
6474
- (this.toolActivityStopping ||
6475
- this.opts.toolActivity?.enabled === false ||
6476
- toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
6477
- !this.toolActivityAuthorityIsCurrent(toolActivity))
6478
- ) {
7243
+ const legacyStart =
7244
+ toolActivity?.phase === "started"
7245
+ ? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
7246
+ : undefined;
7247
+ if (legacyStart !== undefined && legacyStart.owner === toolActivity && legacyStart.phase === "queued")
7248
+ legacyStart.phase = "dispatching";
7249
+ if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) {
6479
7250
  const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6480
7251
  const owner = this.toolActivityOwners.get(key);
6481
7252
  if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6482
7253
  this.toolActivityOwners.delete(key);
7254
+ this.failLegacyToolStart(toolActivity);
6483
7255
  this.pool.settle(item.itemId!, "removed");
6484
7256
  continue;
6485
7257
  }
@@ -6496,14 +7268,32 @@ export class TelegramNotificationDaemon {
6496
7268
  continue;
6497
7269
  }
6498
7270
  try {
6499
- const response = await this.botApi.call("sendMessage", btwDelivery.body, {
7271
+ const { outcome } = await this.callBotApiClassified("sendMessage", btwDelivery.body, {
6500
7272
  noRetry: true,
6501
7273
  signal: btwDelivery.signal,
6502
7274
  });
6503
- const accepted = response && typeof response === "object" && (response as { ok?: unknown }).ok === true;
6504
- const rejected = response && typeof response === "object" && (response as { ok?: unknown }).ok === false;
6505
- btwDelivery.finish(accepted ? "accepted" : rejected ? "not_delivered" : "uncertain");
6506
- this.pool.settle(item.itemId!, accepted ? "accepted" : rejected ? "rejected" : "ambiguous");
7275
+ if (outcome.kind === "retryable") {
7276
+ this.submitPool({
7277
+ sessionId: item.sessionId,
7278
+ lane: item.lane,
7279
+ coalesceKey: item.coalesceKey,
7280
+ deadlineAt: item.deadlineAt,
7281
+ payload: item.payload,
7282
+ });
7283
+ this.pool.settle(item.itemId!, "ambiguous");
7284
+ continue;
7285
+ }
7286
+ btwDelivery.finish(
7287
+ outcome.kind === "accepted"
7288
+ ? "accepted"
7289
+ : outcome.kind === "rejected"
7290
+ ? "not_delivered"
7291
+ : "uncertain",
7292
+ );
7293
+ this.pool.settle(
7294
+ item.itemId!,
7295
+ outcome.kind === "accepted" ? "accepted" : outcome.kind === "rejected" ? "rejected" : "ambiguous",
7296
+ );
6507
7297
  } catch {
6508
7298
  btwDelivery.finish("uncertain");
6509
7299
  this.pool.settle(item.itemId!, "ambiguous");
@@ -6549,7 +7339,7 @@ export class TelegramNotificationDaemon {
6549
7339
  this.pool.settle(item.itemId!, "rejected");
6550
7340
  continue;
6551
7341
  }
6552
- const response = (await this.botApi.call(
7342
+ const { response, outcome } = await this.callBotApiClassified(
6553
7343
  "sendMessage",
6554
7344
  {
6555
7345
  chat_id: this.opts.chatId,
@@ -6557,9 +7347,28 @@ export class TelegramNotificationDaemon {
6557
7347
  text: "Selected!",
6558
7348
  },
6559
7349
  { signal: controller.signal, noRetry: true },
6560
- )) as { ok?: unknown; result?: { message_id?: unknown } };
6561
- const messageId = response.result?.message_id;
6562
- const delivered = response.ok === true && typeof messageId === "number";
7350
+ );
7351
+ if (outcome.kind === "retryable") {
7352
+ this.pool.settle(item.itemId!, "ambiguous");
7353
+ if (this.effects.stopping) {
7354
+ this.finishSelectedAck(selectedAck, { status: "unknown", reason: "shutdown" });
7355
+ } else {
7356
+ const retry = this.pool.submit({
7357
+ sessionId: item.sessionId,
7358
+ lane: item.lane,
7359
+ coalesceKey: item.coalesceKey,
7360
+ deadlineAt: item.deadlineAt,
7361
+ payload: item.payload,
7362
+ });
7363
+ selectedAck.itemId = retry.itemId;
7364
+ selectedAck.state = "queued";
7365
+ selectedAck.controller = undefined;
7366
+ }
7367
+ continue;
7368
+ }
7369
+ const typedResponse = response as { ok?: unknown; result?: { message_id?: unknown } } | undefined;
7370
+ const messageId = typedResponse?.result?.message_id;
7371
+ const delivered = typedResponse?.ok === true && typeof messageId === "number";
6563
7372
  this.finishSelectedAck(
6564
7373
  selectedAck,
6565
7374
  delivered ? { status: "delivered", messageId } : { status: "failed", reason: "telegram_rejected" },
@@ -6573,17 +7382,20 @@ export class TelegramNotificationDaemon {
6573
7382
  }
6574
7383
  continue;
6575
7384
  }
6576
- const { send, topicLease, socketLease } = item.payload;
7385
+ let { send } = item.payload;
7386
+ const { topicLease, socketLease } = item.payload;
6577
7387
  if (
6578
7388
  (socketLease && !this.#leaseTokenAllows(socketLease)) ||
6579
7389
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6580
7390
  ) {
6581
7391
  this.pool.settle(item.itemId!, "rejected");
7392
+ this.failLegacyToolStart(toolActivity);
6582
7393
  continue;
6583
7394
  }
6584
7395
  const topicId = topicLease?.topicId;
6585
7396
  if (topicId && !(await this.pairedChatIsPrivate())) {
6586
7397
  this.pool.settle(item.itemId!, "rejected");
7398
+ this.failLegacyToolStart(toolActivity);
6587
7399
  continue;
6588
7400
  }
6589
7401
  if (
@@ -6591,12 +7403,20 @@ export class TelegramNotificationDaemon {
6591
7403
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6592
7404
  ) {
6593
7405
  this.pool.settle(item.itemId!, "rejected");
7406
+ this.failLegacyToolStart(toolActivity);
6594
7407
  continue;
6595
7408
  }
6596
7409
  if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
6597
7410
  this.pool.settle(item.itemId!, "removed");
7411
+ this.failLegacyToolStart(toolActivity);
6598
7412
  continue;
6599
7413
  }
7414
+ if (
7415
+ toolActivity?.phase === "terminal" &&
7416
+ !this.toolActivitySummariesAreCurrent(toolActivity) &&
7417
+ toolActivity.summaryFreeSend
7418
+ )
7419
+ send = toolActivity.summaryFreeSend;
6600
7420
  // Threaded topic when available; otherwise deliver flat to the paired chat.
6601
7421
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
6602
7422
  const ckey = send.editable ? item.coalesceKey : undefined;
@@ -6605,6 +7425,8 @@ export class TelegramNotificationDaemon {
6605
7425
  this.pool.settle(item.itemId!, "removed");
6606
7426
  continue;
6607
7427
  }
7428
+ const itemOutcomes: BotApiCallOutcome[] = [];
7429
+ const itemBotApi = this.botApiWithOutcomeCollector(itemOutcomes);
6608
7430
  let disposition: "accepted" | "ambiguous" | "rejected" = "accepted";
6609
7431
  try {
6610
7432
  // Draft streaming (opt-in, off by default): stream a live turn frame as a
@@ -6643,7 +7465,7 @@ export class TelegramNotificationDaemon {
6643
7465
  }
6644
7466
  if (send.method === "sendPhoto" && send.photoBase64) {
6645
7467
  // Real photo upload (the default botApi multiparts base64 -> file).
6646
- await this.botApi.call("sendPhoto", {
7468
+ await itemBotApi.call("sendPhoto", {
6647
7469
  chat_id: this.opts.chatId,
6648
7470
  ...threadField,
6649
7471
  photo: send.photoBase64,
@@ -6652,7 +7474,7 @@ export class TelegramNotificationDaemon {
6652
7474
  parse_mode: TELEGRAM_PARSE_MODE,
6653
7475
  });
6654
7476
  } else if (send.method === "sendDocument" && send.documentBase64) {
6655
- await this.botApi.call("sendDocument", {
7477
+ await itemBotApi.call("sendDocument", {
6656
7478
  chat_id: this.opts.chatId,
6657
7479
  ...threadField,
6658
7480
  document: send.documentBase64,
@@ -6682,7 +7504,7 @@ export class TelegramNotificationDaemon {
6682
7504
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6683
7505
  )
6684
7506
  return;
6685
- await this.botApi.call("sendMessage", {
7507
+ await itemBotApi.call("sendMessage", {
6686
7508
  chat_id: this.opts.chatId,
6687
7509
  ...threadField,
6688
7510
  text: chunks[0]!,
@@ -6712,7 +7534,7 @@ export class TelegramNotificationDaemon {
6712
7534
  }
6713
7535
  };
6714
7536
  const richMessageId = await deliverRichWithFallback(
6715
- this.botApi,
7537
+ itemBotApi,
6716
7538
  { chat_id: this.opts.chatId, ...threadField },
6717
7539
  send,
6718
7540
  AbortSignal.any([this.#deliveryAbort.signal, AbortSignal.timeout(30_000)]),
@@ -6746,13 +7568,13 @@ export class TelegramNotificationDaemon {
6746
7568
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6747
7569
  )
6748
7570
  return;
6749
- const res = (await this.botApi.call("editMessageText", {
7571
+ const res = (await itemBotApi.call("editMessageText", {
6750
7572
  chat_id: this.opts.chatId,
6751
7573
  message_id: existingId,
6752
7574
  text: chunks[0],
6753
7575
  parse_mode: TELEGRAM_PARSE_MODE,
6754
7576
  })) as { ok?: boolean; description?: string } | null;
6755
- edited = res?.ok !== false || /not modified/i.test(String(res?.description ?? ""));
7577
+ edited = res?.ok === true || /not modified/i.test(String(res?.description ?? ""));
6756
7578
  } catch {
6757
7579
  edited = false;
6758
7580
  }
@@ -6762,7 +7584,7 @@ export class TelegramNotificationDaemon {
6762
7584
  (!socketLease || this.#leaseTokenAllows(socketLease)) &&
6763
7585
  (!topicLease || this.topicLeaseIsCurrent(topicLease))
6764
7586
  ) {
6765
- const res = (await this.botApi.call("sendMessage", {
7587
+ const res = (await itemBotApi.call("sendMessage", {
6766
7588
  chat_id: this.opts.chatId,
6767
7589
  ...threadField,
6768
7590
  text: chunks[0]!,
@@ -6778,7 +7600,7 @@ export class TelegramNotificationDaemon {
6778
7600
  (topicLease && !this.topicLeaseIsCurrent(topicLease))
6779
7601
  )
6780
7602
  return;
6781
- const res = (await this.botApi.call("sendMessage", {
7603
+ const res = (await itemBotApi.call("sendMessage", {
6782
7604
  chat_id: this.opts.chatId,
6783
7605
  ...threadField,
6784
7606
  text: chunks[0]!,
@@ -6826,11 +7648,79 @@ export class TelegramNotificationDaemon {
6826
7648
  disposition = "ambiguous";
6827
7649
  if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
6828
7650
  } finally {
7651
+ const retryable = itemOutcomes.some(outcome => outcome.kind === "retryable");
7652
+ const accepted = itemOutcomes.some(outcome => outcome.kind === "accepted");
7653
+ const rejected = itemOutcomes.some(outcome => outcome.kind === "rejected");
7654
+ if (retryable) {
7655
+ if (!accepted) {
7656
+ let retryItemId: string | undefined;
7657
+ let retryAllowed = true;
7658
+ if (toolActivity?.phase === "started") {
7659
+ retryAllowed =
7660
+ !this.toolActivityStopping &&
7661
+ !this.stopRequested &&
7662
+ this.opts.toolActivity?.enabled === true &&
7663
+ toolActivity.policyEpoch === this.toolActivityPolicyEpoch &&
7664
+ this.toolActivityAuthorityIsCurrent(toolActivity);
7665
+ if (toolActivity.session.toolActivityCapability === "v1") {
7666
+ const state = this.legacyToolStarts.get(
7667
+ `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`,
7668
+ );
7669
+ retryAllowed =
7670
+ retryAllowed &&
7671
+ state !== undefined &&
7672
+ state.owner === toolActivity &&
7673
+ !state.terminalClaimed;
7674
+ if (state !== undefined && state.owner === toolActivity) {
7675
+ if (retryAllowed) {
7676
+ state.phase = "queued";
7677
+ state.itemId = `legacy-tool-start:${this.nextLegacyToolStartId++}`;
7678
+ retryItemId = state.itemId;
7679
+ } else {
7680
+ this.settleLegacyToolStart(state, "cancelled");
7681
+ }
7682
+ }
7683
+ } else if (retryAllowed) {
7684
+ retryItemId = `tool-activity-start:${this.nextLegacyToolStartId++}`;
7685
+ }
7686
+ } else if (toolActivity?.phase === "terminal" && item.payload.legacyToolStart !== undefined) {
7687
+ retryItemId = `legacy-tool-terminal:${this.nextLegacyToolStartId++}`;
7688
+ }
7689
+ const newerCoalescedItem =
7690
+ toolActivity === undefined &&
7691
+ item.coalesceKey !== undefined &&
7692
+ this.pool.someQueued(
7693
+ queued =>
7694
+ queued.sessionId === item.sessionId &&
7695
+ queued.lane === item.lane &&
7696
+ queued.coalesceKey === item.coalesceKey,
7697
+ );
7698
+ if (retryAllowed && !newerCoalescedItem) {
7699
+ this.submitPool({
7700
+ sessionId: item.sessionId,
7701
+ lane: item.lane,
7702
+ coalesceKey: item.coalesceKey,
7703
+ deadlineAt: item.deadlineAt,
7704
+ ...(retryItemId !== undefined ? { itemId: retryItemId } : {}),
7705
+ payload: item.payload,
7706
+ });
7707
+ }
7708
+ }
7709
+ disposition = "ambiguous";
7710
+ } else if (disposition === "accepted" && !accepted && rejected) {
7711
+ disposition = "rejected";
7712
+ }
6829
7713
  this.pool.settle(item.itemId!, disposition);
7714
+ if (toolActivity?.phase === "started" && !retryable) this.failLegacyToolStart(toolActivity);
7715
+ if (toolActivity?.phase === "terminal" && !retryable) {
7716
+ const state = item.payload.legacyToolStart;
7717
+ if (state !== undefined && this.legacyToolStarts.get(state.key) === state)
7718
+ this.settleLegacyToolStart(state, "terminal");
7719
+ }
6830
7720
  // A terminal tool frame owns the end of this coalescing key even when both
6831
7721
  // edit and fallback delivery fail. Retaining the old message id would leak
6832
7722
  // one entry per failure and let a later reused key edit stale Telegram state.
6833
- if (send.terminal && editKey) {
7723
+ if (send.terminal && editKey && !retryable) {
6834
7724
  this.liveMessages.delete(editKey);
6835
7725
  const owner = this.toolActivityOwners.get(editKey);
6836
7726
  if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
@@ -6866,6 +7756,18 @@ export class TelegramNotificationDaemon {
6866
7756
  }
6867
7757
  }
6868
7758
  if (toolActivity) {
7759
+ const legacyStart = this.legacyToolStarts.get(mapKey);
7760
+ if (
7761
+ legacyStart !== undefined &&
7762
+ legacyStart.owner === toolActivity &&
7763
+ (toolActivity.session.toolActivityCapability !== "v1" ||
7764
+ legacyStart.policyEpoch !== this.toolActivityPolicyEpoch ||
7765
+ !this.toolActivityAuthorityIsCurrent(toolActivity))
7766
+ ) {
7767
+ this.settleLegacyToolStart(legacyStart, "terminal");
7768
+ void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
7769
+ return;
7770
+ }
6869
7771
  const owner = this.toolActivityOwners.get(mapKey);
6870
7772
  if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
6871
7773
  void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
@@ -6874,6 +7776,10 @@ export class TelegramNotificationDaemon {
6874
7776
  }
6875
7777
  this.liveMessages.set(mapKey, messageId);
6876
7778
  if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
7779
+ if (toolActivity) {
7780
+ const state = this.legacyToolStarts.get(mapKey);
7781
+ if (state !== undefined && state.owner === toolActivity) this.settleLegacyToolStart(state, "visible");
7782
+ }
6877
7783
  }
6878
7784
 
6879
7785
  /**
@@ -6892,16 +7798,50 @@ export class TelegramNotificationDaemon {
6892
7798
  socketLease?: { session: SessionSocket; token: number; logicalSessionId: string },
6893
7799
  ): Promise<void> {
6894
7800
  if ((socketLease && !this.#leaseTokenAllows(socketLease)) || !(await this.pairedChatIsPrivate())) return;
7801
+ if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
6895
7802
  if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
6896
7803
  await this.notifyThreadedFallback(socketLease);
7804
+ if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
6897
7805
  if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
7806
+ if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
6898
7807
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
6899
- this.submitPool({
7808
+ if (toolActivity && !this.toolActivityDeliveryIsCurrent(toolActivity)) return;
7809
+ const legacyToolStart =
7810
+ toolActivity?.phase === "started"
7811
+ ? this.legacyToolStarts.get(`${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`)
7812
+ : toolActivity?.phase === "terminal"
7813
+ ? this.legacyToolStartForTerminal(toolActivity)
7814
+ : undefined;
7815
+ if (
7816
+ legacyToolStart !== undefined &&
7817
+ legacyToolStart.owner === toolActivity &&
7818
+ toolActivity?.phase === "started"
7819
+ ) {
7820
+ legacyToolStart.phase = "queued";
7821
+ legacyToolStart.itemId ??= `legacy-tool-start:${this.nextLegacyToolStartId++}`;
7822
+ }
7823
+ const poolItemId =
7824
+ legacyToolStart !== undefined && legacyToolStart.owner === toolActivity
7825
+ ? toolActivity!.phase === "terminal"
7826
+ ? `legacy-tool-terminal:${this.nextLegacyToolStartId++}`
7827
+ : legacyToolStart.itemId
7828
+ : undefined;
7829
+ const submitted = this.submitPool({
6900
7830
  sessionId,
6901
7831
  lane: send.lane,
6902
7832
  coalesceKey: send.coalesceKey,
6903
- payload: { send, ...(socketLease ? { socketLease } : {}), ...(toolActivity ? { toolActivity } : {}) },
7833
+ ...(poolItemId !== undefined ? { itemId: poolItemId } : {}),
7834
+ payload: {
7835
+ send,
7836
+ ...(socketLease ? { socketLease } : {}),
7837
+ ...(toolActivity ? { toolActivity } : {}),
7838
+ ...(legacyToolStart !== undefined && toolActivity?.phase === "terminal" ? { legacyToolStart } : {}),
7839
+ },
6904
7840
  });
7841
+ if (!submitted) {
7842
+ this.settleRejectedLegacyToolSubmission(toolActivity, legacyToolStart);
7843
+ return;
7844
+ }
6905
7845
  await this.flushPool();
6906
7846
  if (socketLease && !this.#leaseTokenAllows(socketLease)) return;
6907
7847
  if (send.identity) this.flatIdentitySent.add(sessionId);
@@ -6920,6 +7860,7 @@ export class TelegramNotificationDaemon {
6920
7860
  ok?: unknown;
6921
7861
  result?: { type?: unknown };
6922
7862
  };
7863
+ if (res === undefined) return "indeterminate";
6923
7864
  if (res?.ok !== true) {
6924
7865
  logger.warn("notifications: getChat privacy check indeterminate (non-success response)");
6925
7866
  return "indeterminate";
@@ -6982,12 +7923,10 @@ export class TelegramNotificationDaemon {
6982
7923
  this.runtime.stopInterval("telegram-flush");
6983
7924
  }
6984
7925
  private async renewOwnershipHeartbeat(): Promise<boolean> {
6985
- return renewDaemonHeartbeat({
7926
+ return renewOwnerHeartbeatSidecar({
6986
7927
  settings: this.opts.settings,
6987
7928
  ownerId: this.opts.ownerId,
6988
7929
  acquisitionId: this.opts.ownerId,
6989
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
6990
- chatId: this.opts.chatId,
6991
7930
  fs: this.fsImpl,
6992
7931
  now: this.opts.now,
6993
7932
  pid: this.opts.pid ?? process.pid,
@@ -7145,7 +8084,7 @@ export class TelegramNotificationDaemon {
7145
8084
  parse_mode: TELEGRAM_PARSE_MODE,
7146
8085
  reply_markup: { inline_keyboard },
7147
8086
  });
7148
- if (response && typeof response === "object" && (response as { ok?: unknown }).ok === false) {
8087
+ if (!response || typeof response !== "object" || (response as { ok?: unknown }).ok !== true) {
7149
8088
  for (const alias of aliases) this.#modelChoiceAliases.delete(alias);
7150
8089
  logger.warn("notifications: failed to send model selection keyboard");
7151
8090
  return false;
@@ -7161,6 +8100,10 @@ export class TelegramNotificationDaemon {
7161
8100
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
7162
8101
  if (msg?.type === "hello") {
7163
8102
  const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
8103
+ const previousToolActivityCapability = session.toolActivityCapability;
8104
+ session.toolActivityCapability = negotiateToolActivityCapability(previousToolActivityCapability, capabilities);
8105
+ if (previousToolActivityCapability === "v1" && session.toolActivityCapability === "v2")
8106
+ this.cleanLegacyToolStartsForCapabilityUpgrade(session);
7164
8107
  if (capabilities.includes("ephemeral_turn_v1")) {
7165
8108
  session.ephemeralCapable = true;
7166
8109
  this.#resumeBtwTurnsForSession(session);
@@ -7172,7 +8115,7 @@ export class TelegramNotificationDaemon {
7172
8115
  return;
7173
8116
  }
7174
8117
  if (session.replayPending) {
7175
- if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
8118
+ if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled !== true || this.toolActivityStopping))
7176
8119
  return;
7177
8120
  const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
7178
8121
  if (!matchingReplay) {
@@ -7526,34 +8469,76 @@ export class TelegramNotificationDaemon {
7526
8469
  return;
7527
8470
  }
7528
8471
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
7529
- const threadedFrame = msg as Record<string, unknown>;
8472
+ let threadedFrame = msg as Record<string, unknown>;
8473
+ let legacyUnknownStart: LegacyToolStartSettlement | undefined;
8474
+ if (threadedFrame.type === "tool_activity" && threadedFrame.phase === "unknown") {
8475
+ const toolCallId = typeof threadedFrame.toolCallId === "string" ? threadedFrame.toolCallId : undefined;
8476
+ const toolName = typeof threadedFrame.toolName === "string" ? threadedFrame.toolName : undefined;
8477
+ const liveKey = toolCallId ? `${this.#logicalSessionId(session)}:tool:${toolCallId}` : undefined;
8478
+ const state = liveKey ? this.legacyToolStarts.get(liveKey) : undefined;
8479
+ if (
8480
+ session.toolActivityCapability !== "v1" ||
8481
+ !state ||
8482
+ state.owner.session !== session ||
8483
+ state.owner.toolName !== toolName ||
8484
+ state.terminalClaimed
8485
+ )
8486
+ return;
8487
+ state.terminalClaimed = true;
8488
+ if (this.cancelUnsentLegacyToolStart(state)) return;
8489
+ if (state.phase === "dispatching") await state.settled;
8490
+ if (
8491
+ state.settledOutcome !== "visible" ||
8492
+ this.legacyToolStarts.get(state.key) !== state ||
8493
+ session.toolActivityCapability !== "v1" ||
8494
+ state.owner.session !== session ||
8495
+ state.policyEpoch !== this.toolActivityPolicyEpoch ||
8496
+ !this.toolActivityAuthorityIsCurrent(state.owner) ||
8497
+ this.toolActivityOwners.get(state.key) !== state.owner ||
8498
+ !this.liveMessages.has(state.key)
8499
+ )
8500
+ return;
8501
+ legacyUnknownStart = state;
8502
+ threadedFrame = { ...this.toolActivityFrameWithoutSummaries(threadedFrame), phase: "cancelled" };
8503
+ }
7530
8504
  const toolActivity = this.toolActivityOwner(session, threadedFrame);
8505
+ if (threadedFrame.type === "tool_activity" && !toolActivity) return;
7531
8506
  const toolAdmissionEpoch = toolActivity
7532
8507
  ? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
7533
8508
  : undefined;
7534
8509
  if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
7535
- const toolStartIsCurrent = (): boolean =>
7536
- toolActivity?.phase !== "started" ||
7537
- (this.toolActivityAuthorityIsCurrent(toolActivity) &&
7538
- !this.toolActivityStopping &&
7539
- this.opts.toolActivity?.enabled !== false &&
7540
- toolAdmissionEpoch === this.toolActivityPolicyEpoch);
8510
+ if (toolActivity?.phase === "started" && toolActivity.session.toolActivityCapability === "v1") {
8511
+ const state = this.admitLegacyToolStart(toolActivity);
8512
+ if (!state) return;
8513
+ }
8514
+ if (legacyUnknownStart && toolActivity?.phase === "terminal") {
8515
+ if (
8516
+ legacyUnknownStart.owner.session !== toolActivity.session ||
8517
+ legacyUnknownStart.key !== `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`
8518
+ )
8519
+ return;
8520
+ }
8521
+ const toolFrameIsCurrent = (): boolean => !toolActivity || this.toolActivityDeliveryIsCurrent(toolActivity);
7541
8522
  const abandonStaleToolStart = (): void => {
7542
8523
  if (toolActivity?.phase !== "started") return;
7543
8524
  const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
7544
8525
  const owner = this.toolActivityOwners.get(key);
7545
8526
  if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
7546
8527
  this.toolActivityOwners.delete(key);
8528
+ this.failLegacyToolStart(toolActivity);
7547
8529
  };
7548
8530
  if (toolActivity) {
7549
8531
  const liveKey = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
7550
8532
  const currentOwner = this.toolActivityOwners.get(liveKey);
7551
8533
  if (toolActivity.phase === "started") {
7552
- if (!toolStartIsCurrent()) return;
8534
+ if (!toolFrameIsCurrent()) {
8535
+ this.failLegacyToolStart(toolActivity);
8536
+ return;
8537
+ }
7553
8538
  this.toolActivityOwners.set(liveKey, toolActivity);
7554
8539
  } else {
7555
8540
  if (currentOwner && currentOwner.session !== session) return;
7556
- if (this.opts.toolActivity?.enabled === false) {
8541
+ if (this.opts.toolActivity?.enabled !== true) {
7557
8542
  if (!currentOwner) return;
7558
8543
  if (!this.liveMessages.has(liveKey)) {
7559
8544
  // A start may already be granted to the serialized dispatcher but not
@@ -7569,8 +8554,21 @@ export class TelegramNotificationDaemon {
7569
8554
  }
7570
8555
  }
7571
8556
  }
7572
- const send = renderThreadedFrame(msg);
7573
- if (!send) return;
8557
+ const summaryFreeToolFrame =
8558
+ toolActivity?.phase === "terminal" ? this.toolActivityFrameWithoutSummaries(threadedFrame) : threadedFrame;
8559
+ const renderedFrame =
8560
+ toolActivity?.phase === "terminal" && !this.toolActivitySummariesAreCurrent(toolActivity)
8561
+ ? summaryFreeToolFrame
8562
+ : threadedFrame;
8563
+ const send = this.renderThreadedFrame(renderedFrame);
8564
+ if (toolActivity?.phase === "terminal") {
8565
+ const summaryFreeSend = this.renderThreadedFrame(summaryFreeToolFrame);
8566
+ if (summaryFreeSend) toolActivity.summaryFreeSend = summaryFreeSend;
8567
+ }
8568
+ if (!send) {
8569
+ this.failLegacyToolStart(toolActivity);
8570
+ return;
8571
+ }
7574
8572
  const transportLogicalSessionId = this.#logicalSessionId(session);
7575
8573
  // Preserve legacy identity routing for direct/non-replay session callers.
7576
8574
  // Authenticated transports never infer topic ownership from display identity:
@@ -7579,19 +8577,27 @@ export class TelegramNotificationDaemon {
7579
8577
  send.identity && !session.logicalSessionIdTrusted && this.sessions.get(session.sessionId) !== session
7580
8578
  ? (this.topicOwnerForIdentity(msg) ?? transportLogicalSessionId)
7581
8579
  : transportLogicalSessionId;
7582
- if (!this.#leaseAllows(session, logicalSessionId)) return;
8580
+ if (!this.#leaseAllows(session, logicalSessionId)) {
8581
+ this.failLegacyToolStart(toolActivity);
8582
+ return;
8583
+ }
7583
8584
  const socketLease = this.#socketLease(session, logicalSessionId);
7584
- if (!socketLease) return;
8585
+ if (!socketLease) {
8586
+ this.failLegacyToolStart(toolActivity);
8587
+ return;
8588
+ }
7585
8589
  const existingTopic = await this.existingTopicForPrivateChat(logicalSessionId);
7586
- if (!toolStartIsCurrent()) {
8590
+ if (!toolFrameIsCurrent()) {
7587
8591
  abandonStaleToolStart();
7588
8592
  return;
7589
8593
  }
7590
8594
  if (
7591
8595
  this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
7592
8596
  this.topics.get(logicalSessionId)?.bindingMalformed
7593
- )
8597
+ ) {
8598
+ this.failLegacyToolStart(toolActivity);
7594
8599
  return;
8600
+ }
7595
8601
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(logicalSessionId)) {
7596
8602
  this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
7597
8603
  return;
@@ -7604,8 +8610,14 @@ export class TelegramNotificationDaemon {
7604
8610
  if (
7605
8611
  this.topics.get(logicalSessionId)?.authorityState === "delete_pending" ||
7606
8612
  this.topics.get(logicalSessionId)?.bindingMalformed
7607
- )
8613
+ ) {
8614
+ this.failLegacyToolStart(toolActivity);
7608
8615
  return;
8616
+ }
8617
+ if (!toolFrameIsCurrent()) {
8618
+ abandonStaleToolStart();
8619
+ return;
8620
+ }
7609
8621
  await this.deliverFlatFallback(logicalSessionId, send, toolActivity, socketLease);
7610
8622
  return;
7611
8623
  }
@@ -7647,7 +8659,7 @@ export class TelegramNotificationDaemon {
7647
8659
  await this.reconcileUserTopicName(topicLease);
7648
8660
  return;
7649
8661
  }
7650
- if (!toolStartIsCurrent()) {
8662
+ if (!toolFrameIsCurrent()) {
7651
8663
  abandonStaleToolStart();
7652
8664
  return;
7653
8665
  }
@@ -7726,7 +8738,7 @@ export class TelegramNotificationDaemon {
7726
8738
  // returns the last chunk's message_id (the reply-routable message).
7727
8739
  const sendHtmlChunks = async (): Promise<number | undefined> => {
7728
8740
  const chunks = splitTelegramHtml(rendered.text);
7729
- let result: { result?: { message_id?: number } } = {};
8741
+ let result: { result?: { message_id?: number } } | undefined;
7730
8742
  for (let i = 0; i < chunks.length; i++) {
7731
8743
  if (!this.#leaseTokenAllows(socketLease) || (topicLease && !this.topicLeaseIsCurrent(topicLease)))
7732
8744
  return undefined;
@@ -7736,9 +8748,10 @@ export class TelegramNotificationDaemon {
7736
8748
  text: chunks[i]!,
7737
8749
  parse_mode: TELEGRAM_PARSE_MODE,
7738
8750
  ...(i === chunks.length - 1 && inline_keyboard.length ? { reply_markup: { inline_keyboard } } : {}),
7739
- })) as { result?: { message_id?: number } };
8751
+ })) as { result?: { message_id?: number } } | undefined;
8752
+ if (result === undefined) return undefined;
7740
8753
  }
7741
- return result.result?.message_id;
8754
+ return result?.result?.message_id;
7742
8755
  };
7743
8756
  const kind = msg.kind === "idle" ? "idle" : "ask";
7744
8757
  if (this.opts.rich?.enabled !== false) {
@@ -8042,6 +9055,7 @@ export class TelegramNotificationDaemon {
8042
9055
  ? "notifications.telegram.rich.enabled"
8043
9056
  : "notifications.telegram.toolActivity.enabled";
8044
9057
  const label = isRichCommand ? "Rich messages" : "Tool activity";
9058
+ const toolActivityChanged = isRichCommand || this.opts.toolActivity?.enabled !== desired;
8045
9059
  try {
8046
9060
  await this.opts.settings.set(settingPath, desired);
8047
9061
  // Confirm only after the global config write is durable.
@@ -8055,13 +9069,26 @@ export class TelegramNotificationDaemon {
8055
9069
  }
8056
9070
  if (isRichCommand) {
8057
9071
  this.opts.rich = { enabled: desired };
8058
- } else {
9072
+ } else if (toolActivityChanged) {
8059
9073
  this.toolActivityPolicyEpoch++;
8060
9074
  this.opts.toolActivity = { enabled: desired };
9075
+ const legacySettlements = this.cancelLegacyToolStartsForPolicyTransition();
8061
9076
  if (!desired) {
8062
- const removedTools = this.pool.removeWhere(
8063
- item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
8064
- );
9077
+ // Unsent legacy starts are cancelled by exact item/frame identity; a
9078
+ // dispatch already inside Bot API is joined through its own settlement.
9079
+ await legacySettlements;
9080
+ await this.flushChain;
9081
+ const removedTools = this.pool.removeWhere(item => {
9082
+ const toolActivity = item.payload.toolActivity;
9083
+ if (!toolActivity) return false;
9084
+ if (toolActivity.phase === "started") return true;
9085
+ // Keep a terminal only when its exact start is already visible. A
9086
+ // queued or pending start is cancelled above and cannot retain a
9087
+ // terminal merely because its owner record has not been swept yet.
9088
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
9089
+ const owner = this.toolActivityOwners.get(key);
9090
+ return !this.liveMessages.has(key) || owner?.session !== toolActivity.session;
9091
+ });
8065
9092
  for (const item of removedTools) {
8066
9093
  const toolActivity = item.payload.toolActivity;
8067
9094
  if (!toolActivity) continue;
@@ -8091,7 +9118,6 @@ export class TelegramNotificationDaemon {
8091
9118
  // future starts are rejected immediately, queued starts are removed,
8092
9119
  // and any already-granted Bot API effect settles before the off
8093
9120
  // acknowledgement. Visible starts may still receive their terminal edit.
8094
- await this.flushChain;
8095
9121
  }
8096
9122
  await reply(`${label}: ${desired ? "on" : "off"}`);
8097
9123
  return;
@@ -8582,12 +9608,10 @@ export class TelegramNotificationDaemon {
8582
9608
  while (this.running) {
8583
9609
  if (await this.controlStopRequested()) break;
8584
9610
  if (
8585
- !(await renewDaemonHeartbeat({
9611
+ !(await renewOwnerHeartbeatSidecar({
8586
9612
  settings: this.opts.settings,
8587
9613
  ownerId: this.opts.ownerId,
8588
9614
  acquisitionId: this.opts.ownerId,
8589
- tokenFingerprint: tokenFingerprint(this.opts.botToken),
8590
- chatId: this.opts.chatId,
8591
9615
  fs: this.fsImpl,
8592
9616
  now: this.opts.now,
8593
9617
  pid: this.opts.pid ?? process.pid,
@@ -8629,6 +9653,11 @@ export class TelegramNotificationDaemon {
8629
9653
  await this.runtime.sleep(10);
8630
9654
  }
8631
9655
  } finally {
9656
+ this.running = false;
9657
+ this.runtime.stop();
9658
+ this.stopOwnershipHeartbeatTimer();
9659
+ const heartbeatJoined = await this.runtime.joinExclusive("telegram-owner-heartbeat", BTW_SHUTDOWN_JOIN_MS);
9660
+ if (!heartbeatJoined) logger.warn("heartbeat join timed out; retaining daemon ownership (release aborted)");
8632
9661
  // A contender must not mutate durable owner state while unwinding startup.
8633
9662
  if (ownershipProved) {
8634
9663
  let toolShutdownError: unknown;
@@ -8644,8 +9673,6 @@ export class TelegramNotificationDaemon {
8644
9673
  if (toolShutdownError) throw toolShutdownError;
8645
9674
  await this.#drainBtwTurns();
8646
9675
  await this.toolTerminalizationChain;
8647
- this.runtime.stop();
8648
- this.stopOwnershipHeartbeatTimer();
8649
9676
  this.stopFlushTimer();
8650
9677
  this.stopScanTimer();
8651
9678
  this.stopTypingTimer();
@@ -8670,7 +9697,7 @@ export class TelegramNotificationDaemon {
8670
9697
  deadline.promise,
8671
9698
  ]);
8672
9699
  clearTimeout(deadlineTimer);
8673
- const quiesced = completed && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
9700
+ const quiesced = completed && heartbeatJoined && (await this.effects.join(BTW_SHUTDOWN_JOIN_MS));
8674
9701
  if (!quiesced || !persisted) {
8675
9702
  logger.warn("notifications: shutdown was not durably quiesced; retaining daemon ownership");
8676
9703
  } else {