@gajae-code/coding-agent 0.11.3 → 0.11.5

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 (213) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +20 -0
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  9. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  12. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  13. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  14. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  15. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  16. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  18. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  19. package/dist/types/main.d.ts +2 -2
  20. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  22. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  23. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
  26. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  27. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  28. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  29. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  30. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  31. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  32. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  33. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  34. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  35. package/dist/types/sdk/broker/broker.d.ts +1 -0
  36. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  37. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  38. package/dist/types/sdk/broker/internal.d.ts +3 -0
  39. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  40. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  41. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  43. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  44. package/dist/types/sdk/bus/config.d.ts +22 -0
  45. package/dist/types/sdk/bus/index.d.ts +162 -2
  46. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  47. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  48. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  52. package/dist/types/sdk/session.d.ts +16 -1
  53. package/dist/types/secrets/index.d.ts +4 -2
  54. package/dist/types/secrets/obfuscator.d.ts +1 -1
  55. package/dist/types/session/agent-session.d.ts +31 -2
  56. package/dist/types/session/artifacts.d.ts +30 -8
  57. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  58. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  59. package/dist/types/session/session-manager.d.ts +88 -18
  60. package/dist/types/session/session-storage.d.ts +27 -0
  61. package/dist/types/session/streaming-output.d.ts +4 -0
  62. package/dist/types/task/executor.d.ts +9 -0
  63. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  64. package/dist/types/tools/ask.d.ts +87 -12
  65. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  66. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  67. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  68. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  69. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  70. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/path-utils.d.ts +9 -14
  73. package/dist/types/tools/render-utils.d.ts +1 -8
  74. package/dist/types/tools/resource-gc.d.ts +14 -0
  75. package/dist/types/utils/image-loading.d.ts +23 -0
  76. package/dist/types/utils/image-resize.d.ts +2 -0
  77. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  78. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  79. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  80. package/dist/types/web/scrapers/types.d.ts +1 -5
  81. package/package.json +25 -9
  82. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  83. package/scripts/run-threat-model-check.ts +60 -0
  84. package/src/cli/notify-cli.ts +40 -18
  85. package/src/cli/worktree-cli.ts +9 -287
  86. package/src/cli.ts +10 -9
  87. package/src/commands/gc.ts +11 -1
  88. package/src/commands/sdk.ts +10 -4
  89. package/src/commands/worktree.ts +10 -53
  90. package/src/config/model-profiles.ts +15 -15
  91. package/src/config/settings-schema.ts +17 -0
  92. package/src/daemon/runtime.ts +2 -2
  93. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  94. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  95. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  96. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  97. package/src/eval/js/shared/runtime.ts +1 -1
  98. package/src/eval/py/index.ts +2 -2
  99. package/src/export/html/template.generated.ts +1 -1
  100. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  101. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  102. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  103. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  104. package/src/gjc-runtime/gc-render.ts +19 -1
  105. package/src/gjc-runtime/gc-runtime.ts +68 -3
  106. package/src/gjc-runtime/launch-tmux.ts +61 -6
  107. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  108. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  109. package/src/gjc-runtime/state-argv.ts +292 -0
  110. package/src/gjc-runtime/state-runtime.ts +80 -222
  111. package/src/gjc-runtime/team-runtime.ts +1 -1
  112. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  113. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  114. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  115. package/src/harness-control-plane/owner.ts +59 -3
  116. package/src/hindsight/client.ts +65 -2
  117. package/src/internal-urls/agent-protocol.ts +76 -5
  118. package/src/internal-urls/docs-index.generated.ts +7 -6
  119. package/src/internal-urls/local-protocol.ts +416 -18
  120. package/src/lsp/index.ts +4 -88
  121. package/src/main.ts +37 -13
  122. package/src/modes/acp/acp-agent.ts +8 -0
  123. package/src/modes/components/ansi-display-validator.ts +54 -0
  124. package/src/modes/components/custom-editor.ts +103 -32
  125. package/src/modes/components/custom-provider-wizard.ts +26 -1
  126. package/src/modes/components/notifications-settings-editor.ts +26 -2
  127. package/src/modes/components/session-observer-overlay.ts +40 -37
  128. package/src/modes/components/settings-selector.ts +3 -1
  129. package/src/modes/components/tool-transcript-format.ts +393 -0
  130. package/src/modes/components/transcript-viewer-overlay.ts +63 -1
  131. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  132. package/src/modes/controllers/input-controller.ts +98 -37
  133. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  134. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  135. package/src/modes/controllers/selector-controller.ts +71 -9
  136. package/src/modes/interactive-mode.ts +14 -2
  137. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  138. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  139. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  140. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  141. package/src/plan-mode/approved-plan.ts +3 -1
  142. package/src/prompts/agents/architect.md +1 -0
  143. package/src/prompts/agents/critic.md +1 -0
  144. package/src/prompts/system/custom-system-prompt.md +1 -1
  145. package/src/prompts/system/system-prompt.md +2 -1
  146. package/src/prompts/tools/lsp.md +3 -3
  147. package/src/runtime-mcp/manager.ts +16 -1
  148. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  149. package/src/sdk/broker/broker.ts +139 -32
  150. package/src/sdk/broker/discovery.ts +125 -0
  151. package/src/sdk/broker/ensure.ts +103 -19
  152. package/src/sdk/broker/internal.ts +10 -0
  153. package/src/sdk/broker/session-index.ts +309 -38
  154. package/src/sdk/broker/state-version.ts +5 -2
  155. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  156. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  157. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  158. package/src/sdk/bus/config-commands.ts +22 -0
  159. package/src/sdk/bus/config.ts +37 -0
  160. package/src/sdk/bus/index.ts +384 -92
  161. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  162. package/src/sdk/bus/notification-service.ts +284 -62
  163. package/src/sdk/bus/recent-activity.ts +14 -5
  164. package/src/sdk/bus/session-control.ts +150 -44
  165. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  166. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  167. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  168. package/src/sdk/bus/telegram-daemon.ts +1842 -281
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/session.ts +155 -27
  171. package/src/secrets/index.ts +53 -10
  172. package/src/secrets/obfuscator.ts +21 -18
  173. package/src/secrets/regex.ts +2 -0
  174. package/src/session/agent-session.ts +495 -253
  175. package/src/session/artifacts.ts +188 -23
  176. package/src/session/context-estimation.ts +7 -2
  177. package/src/session/internal/managed-session-scope.ts +215 -39
  178. package/src/session/internal/managed-session-storage.ts +1152 -46
  179. package/src/session/session-manager.ts +1055 -148
  180. package/src/session/session-storage.ts +109 -11
  181. package/src/session/streaming-output.ts +6 -0
  182. package/src/task/executor.ts +71 -26
  183. package/src/task/index.ts +63 -20
  184. package/src/tool-discovery/tool-index.ts +18 -0
  185. package/src/tools/ask.ts +329 -57
  186. package/src/tools/bash-allowed-prefixes.ts +79 -29
  187. package/src/tools/bash-skill-urls.ts +14 -3
  188. package/src/tools/bash.ts +2 -2
  189. package/src/tools/browser/launch.ts +3 -1
  190. package/src/tools/browser/readable.ts +1 -1
  191. package/src/tools/browser/screenshot-format.ts +34 -0
  192. package/src/tools/browser/tab-protocol.ts +3 -0
  193. package/src/tools/browser/tab-supervisor.ts +125 -122
  194. package/src/tools/browser/tab-worker-entry.ts +33 -14
  195. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  196. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  197. package/src/tools/browser/tab-worker.ts +7 -5
  198. package/src/tools/index.ts +4 -0
  199. package/src/tools/path-utils.ts +42 -35
  200. package/src/tools/plan-mode-guard.ts +1 -0
  201. package/src/tools/read.ts +7 -1
  202. package/src/tools/render-utils.ts +1 -27
  203. package/src/tools/resource-gc.ts +123 -34
  204. package/src/tools/sqlite-reader.ts +3 -2
  205. package/src/tools/subagent.ts +2 -0
  206. package/src/utils/image-loading.ts +85 -34
  207. package/src/utils/image-resize.ts +16 -9
  208. package/src/utils/pasted-image-loading.ts +514 -0
  209. package/src/utils/pasted-image-path.ts +223 -98
  210. package/src/web/scrapers/html-to-markdown.ts +68 -0
  211. package/src/web/scrapers/readthedocs.ts +1 -1
  212. package/src/web/scrapers/types.ts +1 -68
  213. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -9,11 +9,33 @@ import { withFileLock } from "../../config/file-lock";
9
9
  import type { Settings } from "../../config/settings";
10
10
  import type { DaemonRuntimeInfo } from "../../daemon/control-types";
11
11
  import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
12
+ import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
12
13
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
13
- import { parseInThreadConfigCommand, parseRichToggleCommand, parseTelegramControlCommand } from "./config-commands";
14
+ import {
15
+ parseInThreadConfigCommand,
16
+ parseRichToggleCommand,
17
+ parseTelegramControlCommand,
18
+ parseToolActivityToggleCommand,
19
+ } from "./config-commands";
14
20
  import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
15
- import { sanitizeDiagnostic } from "./notification-service";
21
+ import {
22
+ acquireDaemonTransitionLock,
23
+ type DaemonTransitionLock,
24
+ daemonTransitionLockIsHeld,
25
+ exactUnlinkNotificationFile,
26
+ type NotificationEndpointFile,
27
+ type NotificationEndpointFileIdentity,
28
+ type NotificationExactUnlinkResult,
29
+ readNotificationEndpointFile,
30
+ releaseDaemonTransitionLock,
31
+ sanitizeDiagnostic,
32
+ } from "./notification-service";
16
33
  import { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
34
+ import {
35
+ type DaemonProcessReference,
36
+ type TelegramDaemonControlDeps,
37
+ TelegramDaemonController,
38
+ } from "./telegram-daemon-control";
17
39
  import { withTelegramSetupLease } from "./telegram-setup";
18
40
 
19
41
  export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
@@ -79,9 +101,18 @@ export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "bl
79
101
  /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */
80
102
  export type EnsureTelegramDaemonDetailedResult = "spawned" | "reloaded" | "attached" | "disabled" | "blocked_identity";
81
103
 
104
+ export type TelegramDaemonOwnershipPhase = "provisional" | "ready" | "retired";
105
+
82
106
  export interface DaemonState {
83
107
  pid: number;
108
+ /** OS process-start provenance; mandatory for PID-authorized ownership actions. */
109
+ incarnation: string;
84
110
  ownerId: string;
111
+ /** Unique, durable identity for one ownership acquisition. */
112
+ acquisitionId?: string;
113
+ /** A provisional owner is physical-live but MUST NOT be attached as ready. */
114
+ ownershipPhase?: TelegramDaemonOwnershipPhase;
115
+
85
116
  tokenFingerprint: string;
86
117
  chatId: string;
87
118
  startedAt: number;
@@ -95,10 +126,8 @@ export interface DaemonState {
95
126
  version: 1;
96
127
  /**
97
128
  * Operational daemon generation of the process that owns the lock, distinct
98
- * from the persisted state-schema {@link DaemonState.version}. It records the
99
- * wire generation ({@link DAEMON_GENERATION}) the owning daemon speaks so a
100
- * freshly-upgraded host can detect — and reload — a still-live pre-upgrade
101
- * daemon whose schema version is unchanged. Absent on pre-generation state.
129
+ * from both the persisted state-schema {@link DaemonState.version} and the
130
+ * notification wire protocol version. Absent on pre-generation state.
102
131
  */
103
132
  generation?: number;
104
133
  stoppedAt?: number;
@@ -112,9 +141,13 @@ export interface TelegramDaemonFs {
112
141
  open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
113
142
  readdir(path: string): Promise<string[]>;
114
143
  chmod(path: string, mode: number): Promise<void>;
144
+ stat?(path: string): Promise<{ mtimeMs: number }>;
145
+ readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
146
+ exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
115
147
  }
116
148
 
117
149
  export interface SpawnResult {
150
+ pid?: number;
118
151
  unref?: () => void;
119
152
  }
120
153
 
@@ -123,6 +156,10 @@ export interface TelegramDaemonDeps {
123
156
  now?: () => number;
124
157
  pid?: number;
125
158
  pidAlive?: (pid: number) => boolean;
159
+ /** Opens an identity-stable process authority for destructive lifecycle operations. */
160
+ processReference?: (pid: number) => DaemonProcessReference | undefined;
161
+ /** Returns immutable process-start provenance, or undefined when unsupported. */
162
+ pidIncarnation?: (pid: number) => string | undefined;
126
163
  spawn?: (
127
164
  command: string,
128
165
  args: string[],
@@ -140,6 +177,8 @@ export interface TelegramDaemonDeps {
140
177
  sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
141
178
  sleep?: (ms: number) => Promise<void>;
142
179
  waitStepMs?: number;
180
+ /** Bounded startup-readiness timeout; injectable for deterministic handoff tests. */
181
+ readinessTimeoutMs?: number;
143
182
  }
144
183
 
145
184
  export const HEARTBEAT_INTERVAL_MS = 5_000;
@@ -153,16 +192,22 @@ export const ASK_CONTROLS_CAPABILITY = "ask_controls_v1";
153
192
  /** Capability required for tool lifecycle and reasoning-summary frames. */
154
193
  export const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v1";
155
194
 
156
- const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
195
+ const nodeFs: TelegramDaemonFs = {
196
+ ...(fs.promises as unknown as TelegramDaemonFs),
197
+ readEndpointFile: readNotificationEndpointFile,
198
+ exactUnlink: async (file, identity) =>
199
+ exactUnlinkNotificationFile(file, identity, `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`),
200
+ };
157
201
 
158
202
  /**
159
- * Durably persist a `/rich` toggle. A real {@link Settings} exposes
160
- * `flushOrThrow()`, which rejects on a failed config.yml write (its `set()` is a
161
- * fire-and-forget whose background save swallows errors). The lightweight daemon
162
- * settings has no `flushOrThrow` — its `set()` already wrote durably and throws
163
- * on failure — so its plain `flush()` no-op drain is sufficient.
203
+ * Durably persist a daemon-local Telegram delivery toggle. A real
204
+ * {@link Settings} exposes `flushOrThrow()`, which rejects on a failed config.yml
205
+ * write (its `set()` is a fire-and-forget whose background save swallows errors).
206
+ * The lightweight daemon settings has no `flushOrThrow` — its `set()` already
207
+ * wrote durably and throws on failure — so its plain `flush()` no-op drain is
208
+ * sufficient.
164
209
  */
165
- async function flushRichToggleSettings(settings: Settings): Promise<void> {
210
+ async function flushTelegramToggleSettings(settings: Settings): Promise<void> {
166
211
  if (typeof settings.flushOrThrow === "function") {
167
212
  await settings.flushOrThrow();
168
213
  return;
@@ -421,24 +466,14 @@ async function writeJsonAtomic(fsImpl: TelegramDaemonFs, file: string, data: unk
421
466
  await fsImpl.rename(tmp, file);
422
467
  }
423
468
 
424
- async function tryOpenWx(fsImpl: TelegramDaemonFs, file: string): Promise<boolean> {
425
- try {
426
- const handle = await fsImpl.open(file, "wx", 0o600);
427
- await handle.close();
428
- return true;
429
- } catch (error) {
430
- if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
431
- throw error;
432
- }
433
- }
434
469
  function validDaemonPid(pid: unknown): pid is number {
435
470
  return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0;
436
471
  }
437
472
 
438
- async function tryCreateOwnershipLock(
473
+ export async function tryCreateOwnershipLock(
439
474
  fsImpl: TelegramDaemonFs,
440
475
  file: string,
441
- initialization: { pid: number; startedAt: number },
476
+ initialization: OwnershipLockMetadata,
442
477
  ): Promise<boolean> {
443
478
  try {
444
479
  await fsImpl.writeFile(file, `${JSON.stringify(initialization)}\n`, { mode: 0o600, flag: "wx" });
@@ -450,6 +485,270 @@ async function tryCreateOwnershipLock(
450
485
  }
451
486
  }
452
487
 
488
+ type OwnershipLockMetadata = {
489
+ pid: number;
490
+ incarnation: string;
491
+ ownerId?: string;
492
+ acquisitionId?: string;
493
+ startedAt: number;
494
+ };
495
+ type LegacyOwnershipLockMetadata = {
496
+ pid: number;
497
+ incarnation?: string;
498
+ startedAt: number;
499
+ };
500
+ type OwnershipLockRead =
501
+ | { kind: "missing" }
502
+ | { kind: "malformed"; raw: string; mtimeMs?: number }
503
+ | { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
504
+ | { kind: "valid"; metadata: OwnershipLockMetadata };
505
+
506
+ /** Read lock provenance without treating a corrupt legacy artifact as a filesystem failure. */
507
+ export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string): Promise<OwnershipLockRead> {
508
+ let raw: string;
509
+ try {
510
+ raw = await fsImpl.readFile(file, "utf8");
511
+ } catch (error) {
512
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
513
+ throw error;
514
+ }
515
+ try {
516
+ const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
517
+ const pid = value.pid;
518
+ const startedAt = value.startedAt;
519
+ if (validDaemonPid(pid) && typeof startedAt === "number" && Number.isSafeInteger(startedAt)) {
520
+ if (isProcessIncarnation(value.incarnation))
521
+ return {
522
+ kind: "valid",
523
+ metadata: {
524
+ pid,
525
+ incarnation: value.incarnation,
526
+ ...(typeof value.ownerId === "string" && value.ownerId.length > 0 ? { ownerId: value.ownerId } : {}),
527
+ ...(typeof value.acquisitionId === "string" && value.acquisitionId.length > 0
528
+ ? { acquisitionId: value.acquisitionId }
529
+ : {}),
530
+ startedAt,
531
+ },
532
+ };
533
+ return { kind: "legacy", metadata: { pid, startedAt } };
534
+ }
535
+ } catch {}
536
+ const mtimeMs = await fsImpl
537
+ .stat?.(file)
538
+ .then(stat => stat.mtimeMs)
539
+ .catch(() => undefined);
540
+ return { kind: "malformed", raw, ...(mtimeMs === undefined ? {} : { mtimeMs }) };
541
+ }
542
+
543
+ /**
544
+ * A live initializer lock only proves that a concurrent publisher is active.
545
+ * It never proves a ready daemon: legacy or unavailable provenance remains
546
+ * blocked, while a canonical mismatch proves PID reuse and can be reclaimed
547
+ * under the transition lock.
548
+ */
549
+ export function liveOwnershipLockDecision(input: {
550
+ lock: OwnershipLockRead;
551
+ pidAlive: (pid: number) => boolean;
552
+ pidIncarnation: (pid: number) => string | undefined;
553
+ }):
554
+ | { acquired: false; attached: false; blocked: true }
555
+ | { acquired: false; attached: false; provisional: true }
556
+ | undefined {
557
+ if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
558
+ if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
559
+ if (input.lock.kind === "legacy") {
560
+ if (!isProcessIncarnation(input.lock.metadata.incarnation))
561
+ return { acquired: false, attached: false, blocked: true };
562
+ const current = input.pidIncarnation(input.lock.metadata.pid);
563
+ if (!isProcessIncarnation(current)) return { acquired: false, attached: false, blocked: true };
564
+ if (current !== input.lock.metadata.incarnation) return undefined;
565
+ return { acquired: false, attached: false, blocked: true };
566
+ }
567
+ const current = input.pidIncarnation(input.lock.metadata.pid);
568
+ if (!isProcessIncarnation(current)) return { acquired: false, attached: false, blocked: true };
569
+ if (current !== input.lock.metadata.incarnation) return undefined;
570
+ return { acquired: false, attached: false, provisional: true };
571
+ }
572
+
573
+ function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead): boolean {
574
+ if (left.kind !== right.kind) return false;
575
+ if (left.kind === "missing") return true;
576
+ if (left.kind === "malformed" && right.kind === "malformed")
577
+ return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
578
+ if ((left.kind !== "valid" && left.kind !== "legacy") || (right.kind !== "valid" && right.kind !== "legacy"))
579
+ return false;
580
+ return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
581
+ }
582
+
583
+ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
584
+ return Boolean(
585
+ lock.kind === "valid" &&
586
+ state &&
587
+ lock.metadata.ownerId === state.ownerId &&
588
+ lock.metadata.acquisitionId === state.acquisitionId &&
589
+ lock.metadata.pid === state.pid &&
590
+ lock.metadata.incarnation === state.incarnation,
591
+ );
592
+ }
593
+
594
+ function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: DaemonState | undefined): boolean {
595
+ return Boolean(state && isExplicitlyStoppedDaemonState(state) && ownershipLockMatchesState(lock, state));
596
+ }
597
+
598
+ async function transitionLockIsHeldByCaller(input: {
599
+ fs: TelegramDaemonFs;
600
+ path: string;
601
+ lock: DaemonTransitionLock;
602
+ }): Promise<boolean> {
603
+ return await daemonTransitionLockIsHeld(input);
604
+ }
605
+
606
+ function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: OwnershipLockMetadata): boolean {
607
+ return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
608
+ }
609
+
610
+ /**
611
+ * Replace an acquisition's lease only after proving the old lease is unchanged.
612
+ * The transition fence serializes lifecycle writers; the exact read-back prevents
613
+ * a stale binder from overwriting a newer owner if that fence is lost.
614
+ */
615
+ async function rebindOwnershipLock(input: {
616
+ fs: TelegramDaemonFs;
617
+ path: string;
618
+ transitionPath: string;
619
+ transition: DaemonTransitionLock;
620
+ expected: OwnershipLockMetadata;
621
+ rebound: OwnershipLockMetadata;
622
+ }): Promise<boolean> {
623
+ if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
624
+ return false;
625
+ if (!ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.expected)) return false;
626
+ if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
627
+ return false;
628
+ try {
629
+ await writeJsonAtomic(input.fs, input.path, input.rebound);
630
+ } catch {
631
+ return false;
632
+ }
633
+ return (
634
+ (await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })) &&
635
+ ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.rebound)
636
+ );
637
+ }
638
+
639
+ /** Restore the launcher lease only when the failed binder still owns the child lease. */
640
+ async function rollbackOwnershipLockRebind(input: {
641
+ fs: TelegramDaemonFs;
642
+ path: string;
643
+ transitionPath: string;
644
+ transition: DaemonTransitionLock;
645
+ previous: OwnershipLockMetadata;
646
+ rebound: OwnershipLockMetadata;
647
+ }): Promise<boolean> {
648
+ if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
649
+ return false;
650
+ if (!ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.rebound)) return false;
651
+ if (!(await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })))
652
+ return false;
653
+ try {
654
+ await writeJsonAtomic(input.fs, input.path, input.previous);
655
+ } catch {
656
+ return false;
657
+ }
658
+ return (
659
+ (await transitionLockIsHeldByCaller({ fs: input.fs, path: input.transitionPath, lock: input.transition })) &&
660
+ ownershipLockMatchesMetadata(await readOwnershipLock(input.fs, input.path), input.previous)
661
+ );
662
+ }
663
+
664
+ async function ownershipLockIsReclaimable(input: {
665
+ fs: TelegramDaemonFs;
666
+ path: string;
667
+ lock: OwnershipLockRead;
668
+ now: number;
669
+ pidAlive: (pid: number) => boolean;
670
+ pidIncarnation: (pid: number) => string | undefined;
671
+ }): Promise<boolean> {
672
+ if (input.lock.kind === "missing") return true;
673
+ if (input.lock.kind === "malformed") {
674
+ if (!input.fs.stat) return false;
675
+ const stat = await input.fs.stat(input.path).catch(() => undefined);
676
+ return stat !== undefined && input.now - stat.mtimeMs > HEARTBEAT_TTL_MS;
677
+ }
678
+ if (!input.pidAlive(input.lock.metadata.pid)) return true;
679
+ const current = input.pidIncarnation(input.lock.metadata.pid);
680
+ return (
681
+ isProcessIncarnation(input.lock.metadata.incarnation) &&
682
+ isProcessIncarnation(current) &&
683
+ current !== input.lock.metadata.incarnation
684
+ );
685
+ }
686
+
687
+ interface NotificationRootRegistration {
688
+ root?: string;
689
+ managed?: boolean;
690
+ }
691
+
692
+ async function readNotificationRootRegistration(input: {
693
+ settings: Settings;
694
+ sessionId: string;
695
+ fs?: TelegramDaemonFs;
696
+ }): Promise<NotificationRootRegistration> {
697
+ const fsImpl = input.fs ?? nodeFs;
698
+ const current = await readJson<{ sessions?: Record<string, string>; managedRoots?: string[] }>(
699
+ fsImpl,
700
+ daemonPaths(input.settings.getAgentDir()).roots,
701
+ );
702
+ const root = current?.sessions?.[input.sessionId];
703
+ return { root, managed: root !== undefined && current?.managedRoots?.includes(root) === true };
704
+ }
705
+
706
+ /** Restore a session root only if this ensure operation still owns its registration. */
707
+ async function restoreNotificationRootRegistration(input: {
708
+ settings: Settings;
709
+ sessionId: string;
710
+ registeredRoot: string;
711
+ previous: NotificationRootRegistration;
712
+ fs?: TelegramDaemonFs;
713
+ }): Promise<void> {
714
+ const fsImpl = input.fs ?? nodeFs;
715
+ const paths = daemonPaths(input.settings.getAgentDir());
716
+ await ensureDir(fsImpl, paths.dir);
717
+ await withFileLock(
718
+ paths.roots,
719
+ async () => {
720
+ const current =
721
+ (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
722
+ fsImpl,
723
+ paths.roots,
724
+ )) ?? {};
725
+ const sessions = { ...(current.sessions ?? {}) };
726
+ if (sessions[input.sessionId] !== input.registeredRoot) return;
727
+ if (input.previous.root) sessions[input.sessionId] = input.previous.root;
728
+ else delete sessions[input.sessionId];
729
+ const referencedRoots = new Set(Object.values(sessions));
730
+ const roots = new Set(current.roots ?? []);
731
+ const managedRoots = new Set(current.managedRoots ?? []);
732
+ if (input.previous.root) {
733
+ roots.add(input.previous.root);
734
+ if (input.previous.managed) managedRoots.add(input.previous.root);
735
+ else managedRoots.delete(input.previous.root);
736
+ }
737
+ if (managedRoots.has(input.registeredRoot) && !referencedRoots.has(input.registeredRoot)) {
738
+ roots.delete(input.registeredRoot);
739
+ managedRoots.delete(input.registeredRoot);
740
+ }
741
+ await writeJsonAtomic(fsImpl, paths.roots, {
742
+ version: 1,
743
+ roots: Array.from(roots).sort(),
744
+ managedRoots: Array.from(managedRoots).sort(),
745
+ sessions,
746
+ });
747
+ },
748
+ { staleMs: 10_000 },
749
+ );
750
+ }
751
+
453
752
  export async function registerNotificationRoot(input: {
454
753
  settings: Settings;
455
754
  cwd: string;
@@ -464,13 +763,32 @@ export async function registerNotificationRoot(input: {
464
763
  paths.roots,
465
764
  async () => {
466
765
  const current =
467
- (await readJson<{ roots?: string[]; sessions?: Record<string, string> }>(fsImpl, paths.roots)) ?? {};
766
+ (await readJson<{ roots?: string[]; managedRoots?: string[]; sessions?: Record<string, string> }>(
767
+ fsImpl,
768
+ paths.roots,
769
+ )) ?? {};
468
770
  const roots = new Set(current.roots ?? []);
771
+ const managedRoots = new Set(current.managedRoots ?? []);
772
+ const sessions = { ...(current.sessions ?? {}) };
773
+ const previousRoot = sessions[input.sessionId];
774
+ const rootAlreadyPresent = roots.has(root);
469
775
  roots.add(root);
776
+ // Roots present before session registration are legacy/unmanaged and must
777
+ // survive a later session rollback or unregister.
778
+ if (!rootAlreadyPresent) managedRoots.add(root);
779
+ sessions[input.sessionId] = root;
780
+ if (previousRoot && previousRoot !== root && managedRoots.has(previousRoot)) {
781
+ const previousStillReferenced = Object.values(sessions).includes(previousRoot);
782
+ if (!previousStillReferenced) {
783
+ roots.delete(previousRoot);
784
+ managedRoots.delete(previousRoot);
785
+ }
786
+ }
470
787
  await writeJsonAtomic(fsImpl, paths.roots, {
471
788
  version: 1,
472
789
  roots: Array.from(roots).sort(),
473
- sessions: { ...(current.sessions ?? {}), [input.sessionId]: root },
790
+ managedRoots: Array.from(managedRoots).sort(),
791
+ sessions,
474
792
  });
475
793
  },
476
794
  { staleMs: 10_000 },
@@ -498,16 +816,31 @@ export async function unregisterNotificationRoot(input: {
498
816
  await withFileLock(
499
817
  paths.roots,
500
818
  async () => {
501
- const current = await readJson<{ roots?: string[]; sessions?: Record<string, string> }>(fsImpl, paths.roots);
819
+ const current = await readJson<{
820
+ roots?: string[];
821
+ managedRoots?: string[];
822
+ sessions?: Record<string, string>;
823
+ }>(fsImpl, paths.roots);
502
824
  if (!current) return;
503
825
  const sessions = { ...(current.sessions ?? {}) };
826
+ // A stale cleanup from a previous registration must not remove a newer
827
+ // registration for the same session under a different root.
828
+ if (sessions[input.sessionId] !== root) {
829
+ remainingRoots = (current.roots ?? []).length;
830
+ return;
831
+ }
504
832
  delete sessions[input.sessionId];
505
833
  const rootStillReferenced = Object.values(sessions).includes(root);
506
- const roots = (current.roots ?? []).filter(candidate => candidate !== root || rootStillReferenced);
834
+ const managedRoots = new Set(current.managedRoots ?? []);
835
+ const roots = (current.roots ?? []).filter(
836
+ candidate => candidate !== root || rootStillReferenced || !managedRoots.has(root),
837
+ );
838
+ if (!rootStillReferenced) managedRoots.delete(root);
507
839
  remainingRoots = roots.length;
508
840
  await writeJsonAtomic(fsImpl, paths.roots, {
509
841
  version: 1,
510
842
  roots: Array.from(new Set(roots)).sort(),
843
+ managedRoots: Array.from(managedRoots).sort(),
511
844
  sessions,
512
845
  });
513
846
  },
@@ -524,24 +857,212 @@ function validBotToken(token: unknown): token is string {
524
857
  return typeof token === "string" && token.length > 0;
525
858
  }
526
859
 
527
- function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
860
+ function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
861
+ return Boolean(
862
+ hasSafeDaemonStateShape(state) &&
863
+ state.stoppedAt !== undefined &&
864
+ typeof state.acquisitionId === "string" &&
865
+ state.acquisitionId.length > 0,
866
+ );
867
+ }
868
+
869
+ function ownerIdentityMatches(
870
+ state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
871
+ tokenFingerprint: string,
872
+ chatId: string,
873
+ ): boolean {
528
874
  return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
529
875
  }
530
876
 
531
- function liveOwnerUsesDifferentIdentity(input: {
877
+ function ownerProvenanceMatches(state: DaemonState, pidIncarnation?: (pid: number) => string | undefined): boolean {
878
+ const current = (pidIncarnation ?? defaultPidIncarnation)(state.pid);
879
+ return isProcessIncarnation(state.incarnation) && isProcessIncarnation(current) && current === state.incarnation;
880
+ }
881
+
882
+ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
883
+ if (!state || typeof state !== "object" || Array.isArray(state)) return false;
884
+ const candidate = state as Partial<DaemonState>;
885
+ return Boolean(
886
+ Number.isSafeInteger(candidate.pid) &&
887
+ (candidate.pid as number) > 0 &&
888
+ typeof candidate.ownerId === "string" &&
889
+ candidate.ownerId.length > 0 &&
890
+ (candidate.acquisitionId === undefined ||
891
+ (typeof candidate.acquisitionId === "string" && candidate.acquisitionId.length > 0)) &&
892
+ (candidate.ownershipPhase === undefined ||
893
+ candidate.ownershipPhase === "provisional" ||
894
+ candidate.ownershipPhase === "ready" ||
895
+ candidate.ownershipPhase === "retired") &&
896
+ typeof candidate.tokenFingerprint === "string" &&
897
+ typeof candidate.chatId === "string" &&
898
+ Number.isSafeInteger(candidate.startedAt) &&
899
+ Number.isSafeInteger(candidate.heartbeatAt) &&
900
+ isProcessIncarnation(candidate.incarnation) &&
901
+ (candidate.launcherPid === undefined ||
902
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid as number) > 0)) &&
903
+ Array.isArray(candidate.roots) &&
904
+ candidate.roots.every(root => typeof root === "string") &&
905
+ candidate.version === DAEMON_VERSION &&
906
+ (candidate.generation === undefined ||
907
+ (Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
908
+ (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
909
+ );
910
+ }
911
+
912
+ type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
913
+ incarnation?: undefined;
914
+ acquisitionId?: undefined;
915
+ ownershipPhase?: undefined;
916
+ generation?: undefined;
917
+ };
918
+
919
+ interface LegacyMigrationAttestation {
920
+ pid: number;
921
+ incarnation: string;
922
+ heartbeatAt: number;
923
+ observedAt: number;
924
+ }
925
+
926
+ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
927
+ const candidate = state as Partial<LegacyParentDaemonState> | undefined;
928
+ return Boolean(
929
+ candidate &&
930
+ Number.isSafeInteger(candidate.pid) &&
931
+ (candidate.pid ?? 0) > 0 &&
932
+ typeof candidate.ownerId === "string" &&
933
+ candidate.ownerId.length > 0 &&
934
+ typeof candidate.tokenFingerprint === "string" &&
935
+ typeof candidate.chatId === "string" &&
936
+ Number.isSafeInteger(candidate.startedAt) &&
937
+ Number.isSafeInteger(candidate.heartbeatAt) &&
938
+ Array.isArray(candidate.roots) &&
939
+ candidate.roots.every(root => typeof root === "string") &&
940
+ candidate.version === DAEMON_VERSION &&
941
+ candidate.incarnation === undefined &&
942
+ candidate.acquisitionId === undefined &&
943
+ candidate.ownershipPhase === undefined &&
944
+ candidate.generation === undefined &&
945
+ candidate.stoppedAt === undefined,
946
+ );
947
+ }
948
+
949
+ function legacyMigrationAttestationPath(statePath: string): string {
950
+ return `${statePath}.legacy-migration.json`;
951
+ }
952
+
953
+ async function legacyParentHandoffDecision(input: {
954
+ fs: TelegramDaemonFs;
955
+ statePath: string;
956
+ state: LegacyParentDaemonState;
957
+ now: number;
958
+ pidAlive: (pid: number) => boolean;
959
+ pidIncarnation: (pid: number) => string | undefined;
960
+ tokenFingerprint: string;
961
+ chatId: string;
962
+ }): Promise<
963
+ { acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
964
+ > {
965
+ const { state } = input;
966
+ if (!input.pidAlive(state.pid)) return undefined;
967
+ if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
968
+ return { acquired: false, attached: false, blocked: true };
969
+ const incarnation = input.pidIncarnation(state.pid);
970
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
971
+ const attestationPath = legacyMigrationAttestationPath(input.statePath);
972
+ const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
973
+ const attested = Boolean(
974
+ previous &&
975
+ Number.isSafeInteger(previous.pid) &&
976
+ isProcessIncarnation(previous.incarnation) &&
977
+ Number.isSafeInteger(previous.heartbeatAt) &&
978
+ Number.isSafeInteger(previous.observedAt) &&
979
+ previous.pid === state.pid &&
980
+ previous.incarnation === incarnation &&
981
+ state.heartbeatAt > previous.heartbeatAt &&
982
+ input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
983
+ );
984
+ await writeJsonAtomic(input.fs, attestationPath, {
985
+ pid: state.pid,
986
+ incarnation,
987
+ heartbeatAt: state.heartbeatAt,
988
+ observedAt: input.now,
989
+ } satisfies LegacyMigrationAttestation);
990
+ return attested
991
+ ? { acquired: false, attached: false, reloadRequired: true }
992
+ : { acquired: false, attached: false, provisional: true };
993
+ }
994
+
995
+ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
996
+ return (
997
+ generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
998
+ );
999
+ }
1000
+
1001
+ /**
1002
+ * Classifies a physically live daemon record before any replacement action.
1003
+ *
1004
+ * A canonical, differing incarnation is the only authoritative proof that a
1005
+ * live PID is no longer the recorded owner. This applies equally to matching
1006
+ * and foreign Telegram identities: identity never substitutes for provenance.
1007
+ */
1008
+ function classifyForeignLiveOwner(input: {
1009
+ state: unknown;
1010
+ tokenFingerprint: string;
1011
+ chatId: string;
1012
+ pidAlive: (pid: number) => boolean;
1013
+ pidIncarnation?: (pid: number) => string | undefined;
1014
+ }): "identity_mismatch" | "ambiguous" | undefined {
1015
+ const state = input.state as Partial<DaemonState> | undefined;
1016
+ // Validate before probing so malformed PIDs never reach the liveness source.
1017
+ if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1018
+ // A stopped tombstone with a canonical acquisition is not a live owner.
1019
+ if (isExplicitlyStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1020
+ const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1021
+ if (
1022
+ !hasSafeDaemonStateShape(state) ||
1023
+ !isProcessIncarnation(state.incarnation) ||
1024
+ !isProcessIncarnation(currentIncarnation)
1025
+ )
1026
+ return "ambiguous";
1027
+ if (currentIncarnation !== state.incarnation) return undefined;
1028
+ return ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ? undefined : "identity_mismatch";
1029
+ }
1030
+
1031
+ /** True for a physically live owner with this configuration, including legacy generations. */
1032
+ export function isPhysicalMatchingOwner(input: {
532
1033
  state: DaemonState | undefined;
533
1034
  tokenFingerprint: string;
534
1035
  chatId: string;
535
1036
  pidAlive: (pid: number) => boolean;
1037
+ pidIncarnation?: (pid: number) => string | undefined;
536
1038
  }): boolean {
537
1039
  const { state } = input;
538
1040
  return Boolean(
539
1041
  state &&
540
- state.version === DAEMON_VERSION &&
1042
+ hasSafeDaemonStateShape(state) &&
541
1043
  state.stoppedAt === undefined &&
542
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
543
- validDaemonPid(state.pid) &&
544
- input.pidAlive(state.pid),
1044
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1045
+ input.pidAlive(state.pid) &&
1046
+ ownerProvenanceMatches(state, input.pidIncarnation),
1047
+ );
1048
+ }
1049
+
1050
+ /** True only for a signalable legacy owner or this generation's ready bound child. */
1051
+ export function isSignalableMatchingOwner(input: {
1052
+ state: DaemonState | undefined;
1053
+ tokenFingerprint: string;
1054
+ chatId: string;
1055
+ pidAlive: (pid: number) => boolean;
1056
+ pidIncarnation?: (pid: number) => string | undefined;
1057
+ }): boolean {
1058
+ const { state } = input;
1059
+ return Boolean(
1060
+ isPhysicalMatchingOwner(input) &&
1061
+ (isRecognizedLegacyGeneration(state?.generation) ||
1062
+ (state?.generation === DAEMON_GENERATION &&
1063
+ state.ownershipPhase === "ready" &&
1064
+ typeof state.acquisitionId === "string" &&
1065
+ state.acquisitionId.length > 0)),
545
1066
  );
546
1067
  }
547
1068
 
@@ -551,16 +1072,37 @@ export function isFreshLiveOwner(input: {
551
1072
  tokenFingerprint: string;
552
1073
  chatId: string;
553
1074
  pidAlive: (pid: number) => boolean;
1075
+ pidIncarnation?: (pid: number) => string | undefined;
554
1076
  }): boolean {
555
1077
  const { state } = input;
556
1078
  return Boolean(
557
1079
  state &&
558
- state.version === DAEMON_VERSION &&
1080
+ hasSafeDaemonStateShape(state) &&
559
1081
  state.stoppedAt === undefined &&
560
1082
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
561
1083
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
562
- validDaemonPid(state.pid) &&
563
- input.pidAlive(state.pid),
1084
+ input.pidAlive(state.pid) &&
1085
+ ownerProvenanceMatches(state, input.pidIncarnation),
1086
+ );
1087
+ }
1088
+
1089
+ /** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */
1090
+ export function isCurrentCompatibleOwner(input: {
1091
+ state: DaemonState | undefined;
1092
+ now: number;
1093
+ tokenFingerprint: string;
1094
+ chatId: string;
1095
+ pidAlive: (pid: number) => boolean;
1096
+ pidIncarnation?: (pid: number) => string | undefined;
1097
+ }): boolean {
1098
+ const state = input.state;
1099
+ return Boolean(
1100
+ isFreshLiveOwner(input) &&
1101
+ state?.ownershipPhase === "ready" &&
1102
+ typeof state.acquisitionId === "string" &&
1103
+ state.acquisitionId.length > 0 &&
1104
+ Number.isSafeInteger(state.generation) &&
1105
+ (state.generation as number) >= DAEMON_GENERATION,
564
1106
  );
565
1107
  }
566
1108
 
@@ -573,6 +1115,7 @@ export async function acquireDaemonOwnership(input: {
573
1115
  now?: () => number;
574
1116
  pid?: number;
575
1117
  pidAlive?: (pid: number) => boolean;
1118
+ pidIncarnation?: (pid: number) => string | undefined;
576
1119
  randomId?: () => string;
577
1120
  /** Permit one Windows source launcher PID to daemon PID handoff. */
578
1121
  allowPidRebind?: boolean;
@@ -581,8 +1124,10 @@ export async function acquireDaemonOwnership(input: {
581
1124
  }): Promise<{
582
1125
  acquired: boolean;
583
1126
  ownerId?: string;
1127
+ acquisitionId?: string;
584
1128
  attached?: boolean;
585
1129
  blocked?: boolean;
1130
+ provisional?: boolean;
586
1131
  reason?: "identity_mismatch";
587
1132
  reloadRequired?: boolean;
588
1133
  }> {
@@ -591,190 +1136,672 @@ export async function acquireDaemonOwnership(input: {
591
1136
  const pid = input.pid ?? process.pid;
592
1137
  if (!validDaemonPid(pid)) return { acquired: false, attached: true };
593
1138
  const pidAlive = input.pidAlive ?? defaultPidAlive;
1139
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1140
+ const incarnation = pidIncarnation(pid);
1141
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, provisional: true };
594
1142
  const paths = daemonPaths(input.settings.getAgentDir());
595
1143
  await ensureDir(fsImpl, paths.dir);
596
1144
  const ownerId =
597
1145
  input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
598
1146
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
599
- return withFileLock(paths.ownership, async () => {
600
- const lockIsPositivelyStale = async (): Promise<boolean> => {
601
- let initialization: { pid?: unknown; startedAt?: unknown } | undefined;
602
- try {
603
- initialization = await readJson<{ pid?: unknown; startedAt?: unknown }>(fsImpl, paths.lock);
604
- } catch {
605
- // Legacy daemons wrote an empty lock file. With no live state to
606
- // identify an owner, malformed metadata must not wedge startup.
607
- return true;
608
- }
609
- if (!initialization) return true;
1147
+
1148
+ // A fresh, identity-matching live owner running an OLDER generation than this
1149
+ // build cannot serve our newer wire frames; signal a reload instead of a
1150
+ // silent attach. Newer/equal generations attach as before (no downgrade).
1151
+ const attachDecision = (
1152
+ state: DaemonState | undefined,
1153
+ ):
1154
+ | { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
1155
+ | undefined => {
1156
+ if (state && !hasSafeDaemonStateShape(state)) {
1157
+ const malformed = state as Partial<DaemonState>;
610
1158
  if (
611
- !validDaemonPid(initialization.pid) ||
612
- typeof initialization.startedAt !== "number" ||
613
- !Number.isFinite(initialization.startedAt)
1159
+ !isExplicitlyStoppedDaemonState(malformed) &&
1160
+ validDaemonPid(malformed.pid) &&
1161
+ typeof malformed.incarnation === "string" &&
1162
+ pidAlive(malformed.pid)
614
1163
  )
615
- return true;
616
- return now() - initialization.startedAt > HEARTBEAT_TTL_MS && !pidAlive(initialization.pid);
617
- };
618
- const ownershipIsPositivelyStale = async (state: DaemonState | undefined): Promise<boolean> => {
619
- if (state?.stoppedAt !== undefined) return true;
620
- if (state && validDaemonPid(state.pid)) return !pidAlive(state.pid);
621
- return await lockIsPositivelyStale();
622
- };
623
- const writeState = async (): Promise<void> => {
624
- const timestamp = now();
625
- await writeJsonAtomic(fsImpl, paths.state, {
626
- pid,
627
- ownerId,
1164
+ return { acquired: false, attached: false, blocked: true };
1165
+ return undefined;
1166
+ }
1167
+ if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1168
+ return undefined;
1169
+ // Unavailable provenance is ambiguous and must remain fail-closed. A
1170
+ // different authoritative incarnation proves PID reuse, so allow only the
1171
+ // transition-locked path below to reclaim the stale owner artifacts.
1172
+ if (pidAlive(state.pid)) {
1173
+ const currentIncarnation = pidIncarnation(state.pid);
1174
+ if (!isProcessIncarnation(currentIncarnation) || !isProcessIncarnation(state.incarnation))
1175
+ return { acquired: false, attached: false, blocked: true };
1176
+ if (currentIncarnation !== state.incarnation) return undefined;
1177
+ }
1178
+ if (!pidAlive(state.pid)) return undefined;
1179
+ if (
1180
+ isCurrentCompatibleOwner({
1181
+ state,
1182
+ now: now(),
628
1183
  tokenFingerprint: input.tokenFingerprint,
629
1184
  chatId: input.chatId,
630
- startedAt: timestamp,
631
- heartbeatAt: timestamp,
632
- roots,
633
- version: DAEMON_VERSION,
634
- generation: DAEMON_GENERATION,
635
- ...(input.allowPidRebind ? { launcherPid: pid } : {}),
636
- } satisfies DaemonState);
637
- };
638
- const attachDecision = (
639
- state: DaemonState | undefined,
640
- ): { acquired: false; attached: boolean; reloadRequired?: boolean } | undefined => {
641
- if (
642
- !isFreshLiveOwner({
643
- state,
644
- now: now(),
645
- tokenFingerprint: input.tokenFingerprint,
646
- chatId: input.chatId,
647
- pidAlive,
648
- })
649
- ) {
650
- return undefined;
651
- }
652
- return (state?.generation ?? 0) < DAEMON_GENERATION
653
- ? { acquired: false, attached: false, reloadRequired: true }
654
- : { acquired: false, attached: true };
655
- };
656
- const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
657
- liveOwnerUsesDifferentIdentity({
1185
+ pidAlive,
1186
+ pidIncarnation,
1187
+ })
1188
+ )
1189
+ return { acquired: false, attached: true };
1190
+ // A physical owner that cannot prove current compatibility is never safe to
1191
+ // attach. Legacy, stale, and malformed generations require a handoff;
1192
+ // otherwise retain the explicit provisional classification.
1193
+ if (
1194
+ state.version !== DAEMON_VERSION ||
1195
+ !isFreshLiveOwner({
658
1196
  state,
1197
+ now: now(),
659
1198
  tokenFingerprint: input.tokenFingerprint,
660
1199
  chatId: input.chatId,
661
1200
  pidAlive,
1201
+ pidIncarnation,
1202
+ }) ||
1203
+ isRecognizedLegacyGeneration(state.generation)
1204
+ )
1205
+ return { acquired: false, attached: false, reloadRequired: true };
1206
+ return { acquired: false, attached: false, provisional: true };
1207
+ };
1208
+ const existing = await readJson<DaemonState>(fsImpl, paths.state);
1209
+ const foreignOwner = classifyForeignLiveOwner({
1210
+ state: existing,
1211
+ tokenFingerprint: input.tokenFingerprint,
1212
+ chatId: input.chatId,
1213
+ pidAlive,
1214
+ pidIncarnation,
1215
+ });
1216
+ if (foreignOwner) {
1217
+ return foreignOwner === "identity_mismatch"
1218
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1219
+ : { acquired: false, attached: false, blocked: true };
1220
+ }
1221
+
1222
+ const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1223
+ if (!transition) return { acquired: false, attached: false, provisional: true };
1224
+ try {
1225
+ const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
1226
+ const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1227
+ const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
1228
+ if (
1229
+ recheckedDecision &&
1230
+ (recheckedDecision.attached ||
1231
+ recheckedDecision.blocked ||
1232
+ recheckedLock.kind === "missing" ||
1233
+ ownershipLockMatchesState(recheckedLock, rechecked))
1234
+ )
1235
+ return recheckedDecision;
1236
+ // A stopped tombstone authorizes removal only of its own canonical lock.
1237
+ // A newer initializer may have replaced the pathname while that tombstone
1238
+ // remained, and must receive the same liveness/freshness protection as any
1239
+ // other live reservation.
1240
+ const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
1241
+ const lockDecision = stoppedLockMatches
1242
+ ? undefined
1243
+ : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1244
+ if (lockDecision) return lockDecision;
1245
+ if (
1246
+ !stoppedLockMatches &&
1247
+ !(await ownershipLockIsReclaimable({
1248
+ fs: fsImpl,
1249
+ path: paths.lock,
1250
+ lock: recheckedLock,
1251
+ now: now(),
1252
+ pidAlive,
1253
+ pidIncarnation,
1254
+ }))
1255
+ )
1256
+ return { acquired: false, attached: false, provisional: true };
1257
+ if (isLegacyParentDaemonState(rechecked)) {
1258
+ const legacyDecision = await legacyParentHandoffDecision({
1259
+ fs: fsImpl,
1260
+ statePath: paths.state,
1261
+ state: rechecked,
1262
+ now: now(),
1263
+ pidAlive,
1264
+ pidIncarnation,
1265
+ tokenFingerprint: input.tokenFingerprint,
1266
+ chatId: input.chatId,
662
1267
  });
663
- const existing = await readJson<DaemonState>(fsImpl, paths.state);
664
- if (rejectLiveDifferentIdentity(existing)) {
665
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
1268
+ if (legacyDecision) return legacyDecision;
1269
+ } else if (recheckedDecision) {
1270
+ return recheckedDecision;
666
1271
  }
667
- const existingDecision = attachDecision(existing);
668
- if (existingDecision) return existingDecision;
669
- const createdAt = now();
670
- if (await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt })) {
671
- await writeState();
672
- return { acquired: true, ownerId };
1272
+ const recheckedForeignOwner = classifyForeignLiveOwner({
1273
+ state: rechecked,
1274
+ tokenFingerprint: input.tokenFingerprint,
1275
+ chatId: input.chatId,
1276
+ pidAlive,
1277
+ pidIncarnation,
1278
+ });
1279
+ if (recheckedForeignOwner) {
1280
+ return recheckedForeignOwner === "identity_mismatch"
1281
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1282
+ : { acquired: false, attached: false, blocked: true };
673
1283
  }
674
- const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
675
- if (rejectLiveDifferentIdentity(afterLock)) {
676
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
1284
+ if (
1285
+ hasSafeDaemonStateShape(rechecked) &&
1286
+ rechecked.stoppedAt === undefined &&
1287
+ pidAlive(rechecked.pid) &&
1288
+ ownerProvenanceMatches(rechecked, pidIncarnation)
1289
+ )
1290
+ return { acquired: false, attached: false, provisional: true };
1291
+ const currentLock = await readOwnershipLock(fsImpl, paths.lock);
1292
+ if (!ownershipLockMatches(recheckedLock, currentLock))
1293
+ return { acquired: false, attached: false, provisional: true };
1294
+ if (currentLock.kind !== "missing") {
1295
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1296
+ return { acquired: false, attached: false, provisional: true };
1297
+ await fsImpl.unlink(paths.lock);
677
1298
  }
678
- const afterLockDecision = attachDecision(afterLock);
679
- if (afterLockDecision) return afterLockDecision;
680
- if (!afterLock && !(await lockIsPositivelyStale())) return { acquired: false, attached: true };
681
- if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
1299
+ const ownershipLock: OwnershipLockMetadata = {
1300
+ pid,
1301
+ incarnation,
1302
+ ownerId,
1303
+ acquisitionId: ownerId,
1304
+ startedAt: now(),
1305
+ };
1306
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1307
+ return { acquired: false, attached: false, provisional: true };
1308
+ if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, ownershipLock)))
1309
+ return { acquired: false, attached: false, provisional: true };
1310
+ if (
1311
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1312
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1313
+ )
1314
+ return { acquired: false, attached: false, provisional: true };
1315
+ await writeJsonAtomic(fsImpl, paths.state, {
1316
+ pid,
1317
+ incarnation,
1318
+ ownerId,
1319
+ acquisitionId: ownerId,
1320
+ ownershipPhase: "provisional",
1321
+ tokenFingerprint: input.tokenFingerprint,
1322
+ chatId: input.chatId,
1323
+ startedAt: now(),
1324
+ heartbeatAt: now(),
1325
+ roots,
1326
+ version: DAEMON_VERSION,
1327
+ generation: DAEMON_GENERATION,
1328
+ } satisfies DaemonState);
1329
+ if (
1330
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1331
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1332
+ )
1333
+ return { acquired: false, attached: false, provisional: true };
1334
+ return { acquired: true, ownerId, acquisitionId: ownerId };
1335
+ } finally {
1336
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1337
+ }
1338
+ }
1339
+
1340
+ export async function renewDaemonHeartbeat(input: {
1341
+ settings: Settings;
1342
+ ownerId: string;
1343
+ acquisitionId?: string;
1344
+ tokenFingerprint?: string;
1345
+ chatId?: string;
1346
+ fs?: TelegramDaemonFs;
1347
+ now?: () => number;
1348
+ pid?: number;
1349
+ generation?: number;
1350
+ pidIncarnation?: (pid: number) => string | undefined;
1351
+ sleep?: (ms: number) => Promise<void>;
1352
+ stealRetries?: number;
1353
+ stealRetryDelayMs?: number;
1354
+ }): Promise<boolean> {
1355
+ const fsImpl = input.fs ?? nodeFs;
1356
+ const paths = daemonPaths(input.settings.getAgentDir());
1357
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1358
+ // The steal lock is held only briefly by concurrent lifecycle operations.
1359
+ // A contended lock never proves readiness: only the holder may validate and
1360
+ // publish the exact ready PID/generation state.
1361
+ const transition = await acquireTransitionLock({
1362
+ fs: fsImpl,
1363
+ path: paths.steal,
1364
+ pidIncarnation: input.pidIncarnation,
1365
+ sleep: input.sleep,
1366
+ retries: input.stealRetries,
1367
+ retryDelayMs: input.stealRetryDelayMs,
1368
+ });
1369
+ if (!transition) return false;
1370
+ try {
1371
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1372
+ const pid = input.pid ?? state?.pid;
1373
+ const generation = input.generation ?? state?.generation;
1374
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
1375
+ // A daemon child may atomically bind its own PID only while its launcher
1376
+ // reservation is still provisional and carries the same acquisition secret.
1377
+ const canBindProvisionalPid =
1378
+ state?.ownershipPhase === "provisional" &&
1379
+ state.pid !== pid &&
1380
+ state.ownerId === input.ownerId &&
1381
+ state.acquisitionId === acquisitionId;
1382
+ if (
1383
+ !state ||
1384
+ !hasSafeDaemonStateShape(state) ||
1385
+ typeof pid !== "number" ||
1386
+ !Number.isSafeInteger(pid) ||
1387
+ pid <= 0 ||
1388
+ !isProcessIncarnation(incarnation) ||
1389
+ generation !== DAEMON_GENERATION ||
1390
+ state.ownerId !== input.ownerId ||
1391
+ state.acquisitionId !== acquisitionId ||
1392
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1393
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1394
+ (!canBindProvisionalPid && state.incarnation !== incarnation) ||
1395
+ (!canBindProvisionalPid && state.pid !== pid) ||
1396
+ state.generation !== generation ||
1397
+ state.stoppedAt !== undefined ||
1398
+ state.ownershipPhase === "retired"
1399
+ )
1400
+ return false;
1401
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1402
+ const previousLock = canBindProvisionalPid ? await readOwnershipLock(fsImpl, paths.lock) : undefined;
1403
+ const expectedLock =
1404
+ previousLock?.kind === "valid" &&
1405
+ previousLock.metadata.pid === state.pid &&
1406
+ previousLock.metadata.incarnation === state.incarnation &&
1407
+ previousLock.metadata.ownerId === state.ownerId &&
1408
+ previousLock.metadata.acquisitionId === state.acquisitionId
1409
+ ? previousLock.metadata
1410
+ : undefined;
1411
+ const reboundLock = expectedLock ? { ...expectedLock, pid, incarnation } : undefined;
1412
+ if (
1413
+ canBindProvisionalPid &&
1414
+ (!expectedLock ||
1415
+ !reboundLock ||
1416
+ !(await rebindOwnershipLock({
1417
+ fs: fsImpl,
1418
+ path: paths.lock,
1419
+ transitionPath: paths.steal,
1420
+ transition,
1421
+
1422
+ expected: expectedLock,
1423
+ rebound: reboundLock,
1424
+ })))
1425
+ )
1426
+ return false;
682
1427
  try {
683
- const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
684
- const recheckedDecision = attachDecision(rechecked);
685
- if (recheckedDecision) return recheckedDecision;
686
- if (rejectLiveDifferentIdentity(rechecked)) {
687
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
688
- }
689
- if (
690
- rechecked &&
691
- rechecked.stoppedAt === undefined &&
692
- validDaemonPid(rechecked.pid) &&
693
- pidAlive(rechecked.pid)
694
- ) {
695
- return { acquired: false, attached: true };
696
- }
697
- if (!rechecked && !(await lockIsPositivelyStale())) {
698
- return { acquired: false, attached: true };
699
- }
700
- if (!(await ownershipIsPositivelyStale(rechecked))) {
701
- return { acquired: false, attached: true };
702
- }
703
- await fsImpl.unlink(paths.lock).catch(() => undefined);
704
- if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt }))) {
705
- return { acquired: false, attached: true };
706
- }
707
- await writeState();
708
- return { acquired: true, ownerId };
709
- } finally {
710
- await fsImpl.unlink(paths.steal).catch(() => undefined);
1428
+ await writeJsonAtomic(fsImpl, paths.state, {
1429
+ ...state,
1430
+ // Preserve the source launcher PID so concurrent ensures can recognize
1431
+ // this ready PID as its child rather than excluding it as the launcher.
1432
+ launcherPid: state.pid,
1433
+ pid,
1434
+ incarnation,
1435
+ ownershipPhase: "ready",
1436
+ heartbeatAt: (input.now ?? Date.now)(),
1437
+ });
1438
+ } catch {
1439
+ if (expectedLock && reboundLock)
1440
+ await rollbackOwnershipLockRebind({
1441
+ fs: fsImpl,
1442
+ path: paths.lock,
1443
+ transitionPath: paths.steal,
1444
+ transition,
1445
+
1446
+ previous: expectedLock,
1447
+ rebound: reboundLock,
1448
+ });
1449
+ return false;
711
1450
  }
1451
+ return true;
1452
+ } finally {
1453
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1454
+ }
1455
+ }
1456
+
1457
+ /** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */
1458
+ export async function acquireTransitionLock(input: {
1459
+ fs: TelegramDaemonFs;
1460
+ path: string;
1461
+ pidAlive?: (pid: number) => boolean;
1462
+ pidIncarnation?: (pid: number) => string | undefined;
1463
+ sleep?: (ms: number) => Promise<void>;
1464
+ retries?: number;
1465
+ retryDelayMs?: number;
1466
+ }): Promise<DaemonTransitionLock | undefined> {
1467
+ return await acquireDaemonTransitionLock({
1468
+ fs: input.fs,
1469
+ path: input.path,
1470
+ pid: process.pid,
1471
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1472
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1473
+ sleep: input.sleep,
1474
+ retries: input.retries,
1475
+ retryDelayMs: input.retryDelayMs,
712
1476
  });
713
1477
  }
714
1478
 
715
- export async function renewDaemonHeartbeat(input: {
1479
+ /** Retire only the unchanged provisional acquisition after bounded readiness fails. */
1480
+ export async function retireProvisionalDaemonOwnership(input: {
1481
+ settings: Settings;
1482
+ ownerId: string;
1483
+ acquisitionId?: string;
1484
+ pidIncarnation?: (pid: number) => string | undefined;
1485
+ pidAlive?: (pid: number) => boolean;
1486
+ /** Detached child PID, when the launcher successfully reported it. */
1487
+ pid: number;
1488
+ /** PID which created the provisional reservation before the child was bound. */
1489
+ launcherPid?: number;
1490
+ fs?: TelegramDaemonFs;
1491
+ now?: () => number;
1492
+ sleep?: (ms: number) => Promise<void>;
1493
+ stealRetries?: number;
1494
+ stealRetryDelayMs?: number;
1495
+ /** Only no-child confirmation may retire a ready-like launcher publication. */
1496
+ allowReadyWithoutChildPid?: boolean;
1497
+ }): Promise<boolean> {
1498
+ const fsImpl = input.fs ?? nodeFs;
1499
+ const paths = daemonPaths(input.settings.getAgentDir());
1500
+ const transition = await acquireTransitionLock({
1501
+ fs: fsImpl,
1502
+ path: paths.steal,
1503
+ pidAlive: input.pidAlive,
1504
+ pidIncarnation: input.pidIncarnation,
1505
+ sleep: input.sleep,
1506
+ retries: input.stealRetries,
1507
+ retryDelayMs: input.stealRetryDelayMs,
1508
+ });
1509
+ if (!transition) return false;
1510
+ try {
1511
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1512
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1513
+ const expectedPid = state?.pid === input.pid ? input.pid : input.launcherPid;
1514
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1515
+ const expectedPidAlive = expectedPid !== undefined && pidAlive(expectedPid);
1516
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(expectedPid ?? 0);
1517
+ if (
1518
+ !state ||
1519
+ state.ownerId !== input.ownerId ||
1520
+ state.acquisitionId !== acquisitionId ||
1521
+ (expectedPidAlive &&
1522
+ (!isProcessIncarnation(incarnation) ||
1523
+ !isProcessIncarnation(state.incarnation) ||
1524
+ state.incarnation !== incarnation)) ||
1525
+ (state.pid !== input.pid && state.pid !== input.launcherPid) ||
1526
+ state.generation !== DAEMON_GENERATION ||
1527
+ !Number.isSafeInteger(state.generation) ||
1528
+ (state.ownershipPhase !== "provisional" &&
1529
+ !(input.allowReadyWithoutChildPid === true && state.ownershipPhase === "ready"))
1530
+ )
1531
+ return false;
1532
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1533
+ await writeJsonAtomic(fsImpl, paths.state, {
1534
+ ...state,
1535
+ ownershipPhase: "retired",
1536
+ stoppedAt: (input.now ?? Date.now)(),
1537
+ });
1538
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1539
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
1540
+ return true;
1541
+ } finally {
1542
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1543
+ }
1544
+ }
1545
+
1546
+ /** Bind a launcher-reserved provisional acquisition to its actual detached child. */
1547
+ async function bindProvisionalDaemonPid(input: {
716
1548
  settings: Settings;
717
1549
  ownerId: string;
718
- /** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
719
- tokenFingerprint: string;
720
- chatId: string;
721
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
1550
+ acquisitionId: string;
722
1551
  pid: number;
1552
+ incarnation: string;
723
1553
  fs?: TelegramDaemonFs;
724
- now?: () => number;
1554
+ sleep?: (ms: number) => Promise<void>;
1555
+ stealRetries?: number;
1556
+ stealRetryDelayMs?: number;
725
1557
  }): Promise<boolean> {
726
1558
  const fsImpl = input.fs ?? nodeFs;
727
1559
  const paths = daemonPaths(input.settings.getAgentDir());
728
- return withFileLock(paths.ownership, async () => {
1560
+ const transition = await acquireTransitionLock({
1561
+ fs: fsImpl,
1562
+ path: paths.steal,
1563
+ sleep: input.sleep,
1564
+ retries: input.stealRetries,
1565
+ retryDelayMs: input.stealRetryDelayMs,
1566
+ });
1567
+ if (!transition) return false;
1568
+ try {
729
1569
  const state = await readJson<DaemonState>(fsImpl, paths.state);
730
1570
  if (
731
1571
  !state ||
732
- state.stoppedAt !== undefined ||
733
- typeof input.tokenFingerprint !== "string" ||
734
- typeof input.chatId !== "string" ||
735
- !Number.isSafeInteger(input.pid) ||
736
- input.pid <= 0 ||
737
1572
  state.ownerId !== input.ownerId ||
738
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId)
1573
+ state.acquisitionId !== input.acquisitionId ||
1574
+ state.ownershipPhase !== "provisional" ||
1575
+ state.generation !== DAEMON_GENERATION ||
1576
+ !isProcessIncarnation(state.incarnation) ||
1577
+ !isProcessIncarnation(input.incarnation)
739
1578
  )
740
1579
  return false;
741
- if (!validDaemonPid(state.pid)) return false;
742
- if (state.pid !== input.pid && (!validDaemonPid(state.launcherPid) || state.pid !== state.launcherPid))
1580
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1581
+ const previousLock = await readOwnershipLock(fsImpl, paths.lock);
1582
+ if (
1583
+ previousLock.kind !== "valid" ||
1584
+ previousLock.metadata.pid !== state.pid ||
1585
+ previousLock.metadata.incarnation !== state.incarnation ||
1586
+ previousLock.metadata.ownerId !== state.ownerId ||
1587
+ previousLock.metadata.acquisitionId !== state.acquisitionId
1588
+ )
743
1589
  return false;
744
- await writeJsonAtomic(fsImpl, paths.state, {
745
- ...state,
746
- pid: input.pid,
747
- heartbeatAt: (input.now ?? Date.now)(),
748
- });
1590
+ const reboundLock = { ...previousLock.metadata, pid: input.pid, incarnation: input.incarnation };
1591
+ if (
1592
+ !(await rebindOwnershipLock({
1593
+ fs: fsImpl,
1594
+ path: paths.lock,
1595
+ transitionPath: paths.steal,
1596
+ transition,
1597
+
1598
+ expected: previousLock.metadata,
1599
+ rebound: reboundLock,
1600
+ }))
1601
+ )
1602
+ return false;
1603
+ try {
1604
+ await writeJsonAtomic(fsImpl, paths.state, {
1605
+ ...state,
1606
+ // This durable marker distinguishes a launcher reservation from a PID
1607
+ // the launcher authoritatively rebound to its child.
1608
+ launcherPid: state.launcherPid ?? state.pid,
1609
+ pid: input.pid,
1610
+ incarnation: input.incarnation,
1611
+ });
1612
+ } catch {
1613
+ await rollbackOwnershipLockRebind({
1614
+ fs: fsImpl,
1615
+ path: paths.lock,
1616
+ transitionPath: paths.steal,
1617
+ transition,
1618
+ previous: previousLock.metadata,
1619
+ rebound: reboundLock,
1620
+ });
1621
+ return false;
1622
+ }
749
1623
  return true;
750
- });
1624
+ } finally {
1625
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1626
+ }
751
1627
  }
752
1628
 
753
- export async function releaseDaemonOwnership(input: {
1629
+ /** Wait for a matching current-generation daemon to publish a ready state. */
1630
+ export async function waitForTelegramDaemonReady(input: {
754
1631
  settings: Settings;
755
- ownerId: string;
756
- /** Bind release to the Telegram identity that acquired the lock. */
1632
+ ownerId?: string;
1633
+ acquisitionId?: string;
1634
+ pid?: number;
1635
+ excludedPid?: number;
1636
+ tokenFingerprint: string;
1637
+ chatId: string;
1638
+ fs?: TelegramDaemonFs;
1639
+ now?: () => number;
1640
+ pidAlive?: (pid: number) => boolean;
1641
+ pidIncarnation?: (pid: number) => string | undefined;
1642
+ sleep?: (ms: number) => Promise<void>;
1643
+ waitStepMs?: number;
1644
+ timeoutMs?: number;
1645
+ }): Promise<boolean> {
1646
+ const now = input.now ?? Date.now;
1647
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1648
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1649
+ const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
1650
+ const timeoutMs = Math.max(input.timeoutMs ?? 8_000, 0);
1651
+ const waitStepMs = Math.max(input.waitStepMs ?? 25, 1);
1652
+ const deadline = now() + timeoutMs;
1653
+ const maxPolls = Math.ceil(timeoutMs / waitStepMs);
1654
+ for (let poll = 0; poll <= maxPolls; poll++) {
1655
+ const state = await readDaemonState(input.settings, input.fs);
1656
+ if (
1657
+ (!input.ownerId || state?.ownerId === input.ownerId) &&
1658
+ (!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
1659
+ state?.ownershipPhase === "ready" &&
1660
+ state.generation === DAEMON_GENERATION &&
1661
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1662
+ ownerProvenanceMatches(state, pidIncarnation) &&
1663
+ pidAlive(state.pid) &&
1664
+ (Number.isSafeInteger(input.pid) && (input.pid as number) > 0
1665
+ ? state.pid === input.pid
1666
+ : !Number.isSafeInteger(input.excludedPid) || state.pid !== input.excludedPid) &&
1667
+ isCurrentCompatibleOwner({
1668
+ state,
1669
+ now: now(),
1670
+ tokenFingerprint: input.tokenFingerprint,
1671
+ chatId: input.chatId,
1672
+ pidAlive,
1673
+ pidIncarnation,
1674
+ })
1675
+ )
1676
+ return true;
1677
+ if (now() >= deadline || poll === maxPolls) break;
1678
+ await sleep(waitStepMs);
1679
+ }
1680
+ return false;
1681
+ }
1682
+
1683
+ /** Confirm the provisional owner or retire only its unchanged acquisition. */
1684
+ export async function confirmTelegramDaemonSpawn(input: {
1685
+ settings: Settings;
1686
+ spawned: TelegramSpawnOwnerResult;
757
1687
  tokenFingerprint: string;
758
1688
  chatId: string;
759
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
760
1689
  pid: number;
761
1690
  fs?: TelegramDaemonFs;
762
1691
  now?: () => number;
1692
+ pidAlive?: (pid: number) => boolean;
1693
+ pidIncarnation?: (pid: number) => string | undefined;
1694
+ sleep?: (ms: number) => Promise<void>;
1695
+ waitStepMs?: number;
1696
+ timeoutMs?: number;
1697
+ }): Promise<boolean> {
1698
+ if (input.spawned.result !== "owner_spawned") return true;
1699
+ const childPid = input.spawned.acquisition.pid;
1700
+ const hasExactChildPid = Number.isSafeInteger(childPid) && (childPid as number) > 0;
1701
+ const ready = await waitForTelegramDaemonReady({
1702
+ settings: input.settings,
1703
+ ownerId: input.spawned.acquisition.ownerId,
1704
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1705
+ pid: hasExactChildPid ? childPid : undefined,
1706
+ excludedPid: hasExactChildPid ? undefined : input.pid,
1707
+ tokenFingerprint: input.tokenFingerprint,
1708
+ chatId: input.chatId,
1709
+ fs: input.fs,
1710
+ now: input.now,
1711
+ pidAlive: input.pidAlive,
1712
+ pidIncarnation: input.pidIncarnation,
1713
+ sleep: input.sleep,
1714
+ waitStepMs: input.waitStepMs,
1715
+ timeoutMs: input.timeoutMs,
1716
+ });
1717
+ if (ready) return true;
1718
+ const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
1719
+ const retired = await retireProvisionalDaemonOwnership({
1720
+ settings: input.settings,
1721
+ ownerId: input.spawned.acquisition.ownerId,
1722
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1723
+ pid: hasExactChildPid ? (childPid as number) : launcherPid,
1724
+ launcherPid,
1725
+ allowReadyWithoutChildPid: !hasExactChildPid,
1726
+ fs: input.fs,
1727
+ now: input.now,
1728
+ pidAlive: input.pidAlive,
1729
+ pidIncarnation: input.pidIncarnation,
1730
+ sleep: input.sleep,
1731
+ });
1732
+ if (retired) return false;
1733
+ const state = await readDaemonState(input.settings, input.fs);
1734
+ if (
1735
+ hasExactChildPid &&
1736
+ isCurrentCompatibleOwner({
1737
+ state,
1738
+ now: (input.now ?? Date.now)(),
1739
+ tokenFingerprint: input.tokenFingerprint,
1740
+ chatId: input.chatId,
1741
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1742
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1743
+ }) &&
1744
+ (state?.ownerId !== input.spawned.acquisition.ownerId ||
1745
+ state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
1746
+ state.pid === childPid)
1747
+ )
1748
+ return true;
1749
+ if (
1750
+ state?.ownerId === input.spawned.acquisition.ownerId &&
1751
+ state.acquisitionId === input.spawned.acquisition.acquisitionId &&
1752
+ (state.pid === childPid || state.pid === launcherPid || state.pid === input.pid) &&
1753
+ state.ownershipPhase === "retired"
1754
+ )
1755
+ return false;
1756
+ throw new Error("Telegram daemon provisional ownership could not be retired safely");
1757
+ }
1758
+
1759
+ export async function releaseDaemonOwnership(input: {
1760
+ settings: Settings;
1761
+ ownerId: string;
1762
+ acquisitionId?: string;
1763
+ tokenFingerprint?: string;
1764
+ chatId?: string;
1765
+ pid?: number;
1766
+ generation?: number;
1767
+ pidIncarnation?: (pid: number) => string | undefined;
1768
+ fs?: TelegramDaemonFs;
1769
+ now?: () => number;
763
1770
  }): Promise<void> {
764
1771
  const fsImpl = input.fs ?? nodeFs;
765
1772
  const paths = daemonPaths(input.settings.getAgentDir());
766
- await withFileLock(paths.ownership, async () => {
1773
+ const transition = await acquireTransitionLock({
1774
+ fs: fsImpl,
1775
+ path: paths.steal,
1776
+ pidIncarnation: input.pidIncarnation,
1777
+ });
1778
+ if (!transition) return;
1779
+ try {
767
1780
  const state = await readJson<DaemonState>(fsImpl, paths.state);
1781
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1782
+ const pid = input.pid ?? state?.pid;
1783
+ const generation = input.generation ?? state?.generation;
1784
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
768
1785
  if (
769
- !state ||
1786
+ !hasSafeDaemonStateShape(state) ||
770
1787
  state.ownerId !== input.ownerId ||
771
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
772
- state.pid !== input.pid
1788
+ state.acquisitionId !== acquisitionId ||
1789
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1790
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1791
+ state.pid !== pid ||
1792
+ !isProcessIncarnation(incarnation) ||
1793
+ state.incarnation !== incarnation ||
1794
+ state.generation !== generation ||
1795
+ !Number.isSafeInteger(generation)
773
1796
  )
774
1797
  return;
1798
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
775
1799
  await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
776
- await fsImpl.unlink(paths.lock).catch(() => undefined);
777
- });
1800
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1801
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
1802
+ } finally {
1803
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1804
+ }
778
1805
  }
779
1806
 
780
1807
  /** Read the persisted daemon ownership state (or undefined when absent). */
@@ -803,6 +1830,15 @@ function defaultPidAlive(pid: number): boolean {
803
1830
  }
804
1831
  }
805
1832
 
1833
+ /**
1834
+ * Use immutable process-start provenance on every supported OS. In particular,
1835
+ * Windows is authorized by the native process API or PowerShell StartTime/FileTime;
1836
+ * absent authority fails closed and never trusts the numeric PID alone.
1837
+ */
1838
+ function defaultPidIncarnation(pid: number): string | undefined {
1839
+ return processIncarnation(pid);
1840
+ }
1841
+
806
1842
  /** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
807
1843
  function isAbortError(err: unknown): boolean {
808
1844
  return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
@@ -828,7 +1864,7 @@ function defaultDaemonSpawn(
828
1864
  const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
829
1865
  // Best-effort autostart: a spawn failure must never crash the host session.
830
1866
  child.on("error", () => undefined);
831
- return { unref: () => child.unref() };
1867
+ return { pid: child.pid, unref: () => child.unref() };
832
1868
  }
833
1869
 
834
1870
  export interface TelegramSpawnOwnerInput {
@@ -838,19 +1874,20 @@ export interface TelegramSpawnOwnerInput {
838
1874
  chatId: string;
839
1875
  }
840
1876
 
841
- export interface TelegramSpawnOwnerResult {
842
- result: EnsureDaemonResult;
843
- ownerId?: string;
844
- runtime: DaemonRuntimeInfo;
845
- warnings: string[];
846
- /**
847
- * Set when ownership was NOT acquired because a still-live owner is running an
848
- * older daemon generation. The caller must hand off via a reload rather than
849
- * attach; see {@link ensureTelegramDaemonRunning}.
850
- */
851
- reloadRequired?: boolean;
1877
+ export interface TelegramSpawnAcquisition {
1878
+ readonly ownerId: string;
1879
+ readonly acquisitionId: string;
1880
+ /** PID of the launcher which reserved provisional ownership before spawn. */
1881
+ readonly launcherPid?: number;
1882
+ /** Actual detached child which must publish the ready owner state. */
1883
+ readonly pid?: number;
852
1884
  }
853
1885
 
1886
+ export type TelegramSpawnOwnerResult =
1887
+ | { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
1888
+ | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
1889
+ | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
1890
+
854
1891
  /**
855
1892
  * Build the detached spawn command/args for the daemon-internal entrypoint.
856
1893
  * Source mode prepends the entry script so the respawn loads edited source;
@@ -910,26 +1947,41 @@ export async function spawnTelegramDaemonOwner(
910
1947
  now: deps.now,
911
1948
  pid: deps.pid,
912
1949
  pidAlive: deps.pidAlive,
1950
+ pidIncarnation: deps.pidIncarnation,
913
1951
  randomId: ownerId ? undefined : deps.randomId,
914
1952
  ownerId,
915
- allowPidRebind: true,
916
- });
917
- // Build args from the shared runtime resolver.
918
- const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
919
- execPath,
920
- ownerId: ownership.ownerId ?? "",
921
- agentDir,
922
1953
  });
923
1954
  if (!ownership.acquired) {
924
- if (ownership.blocked) {
1955
+ if (ownership.blocked || ownership.provisional) {
925
1956
  return {
926
1957
  result: "blocked",
927
- runtime,
928
- warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
1958
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
1959
+ warnings: [
1960
+ ownership.provisional
1961
+ ? "telegram daemon ownership is provisional; refusing to attach"
1962
+ : "live telegram daemon uses a different bot token or chat; refusing to attach",
1963
+ ],
929
1964
  };
930
1965
  }
931
- return { result: "attached", runtime, warnings: [], reloadRequired: ownership.reloadRequired };
1966
+ return {
1967
+ result: "attached",
1968
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
1969
+ warnings: [],
1970
+ reloadRequired: ownership.reloadRequired,
1971
+ };
932
1972
  }
1973
+ const launcherPid = deps.pid ?? process.pid;
1974
+ const provisionalAcquisition: TelegramSpawnAcquisition = Object.freeze({
1975
+ ownerId: ownership.ownerId as string,
1976
+ acquisitionId: ownership.acquisitionId as string,
1977
+ launcherPid,
1978
+ });
1979
+ // One source of truth for runtime detection + spawn args (no duplicate resolve).
1980
+ const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
1981
+ execPath,
1982
+ ownerId: provisionalAcquisition.ownerId,
1983
+ agentDir,
1984
+ });
933
1985
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
934
1986
  const child = spawnImpl(command, args, {
935
1987
  detached: true,
@@ -937,7 +1989,25 @@ export async function spawnTelegramDaemonOwner(
937
1989
  logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
938
1990
  });
939
1991
  child?.unref?.();
940
- return { result: "owner_spawned", ownerId: ownership.ownerId, runtime, warnings: [] };
1992
+ // A launcher can reserve ownership, but only the actual child can become
1993
+ // ready. Missing PID provenance is intentionally non-ready and times out.
1994
+ const childPid = child?.pid;
1995
+ if (!Number.isSafeInteger(childPid) || (childPid as number) <= 0)
1996
+ return { result: "owner_spawned", acquisition: provisionalAcquisition, runtime, warnings: [] };
1997
+ const pid = childPid as number;
1998
+ const acquisition: TelegramSpawnAcquisition = Object.freeze({ ...provisionalAcquisition, pid });
1999
+ const incarnation = (deps.pidIncarnation ?? defaultPidIncarnation)(pid);
2000
+ if (!incarnation) return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2001
+ await bindProvisionalDaemonPid({
2002
+ settings: input.settings,
2003
+ ownerId: acquisition.ownerId,
2004
+ acquisitionId: acquisition.acquisitionId,
2005
+ incarnation,
2006
+ pid,
2007
+ fs: deps.fs,
2008
+ sleep: deps.sleep,
2009
+ });
2010
+ return { result: "owner_spawned", acquisition, runtime, warnings: [] };
941
2011
  }
942
2012
 
943
2013
  /**
@@ -950,25 +2020,94 @@ export async function ensureTelegramDaemonRunningDetailed(
950
2020
  ): Promise<EnsureTelegramDaemonDetailedResult> {
951
2021
  const cfg = getNotificationConfig(input.settings);
952
2022
  if (!isTelegramConfigured(cfg)) return "disabled";
953
- return await withTelegramSetupLease(cfg.botToken, async () => {
954
- const root = notificationRootForCwd(input.cwd);
955
- const fp = tokenFingerprint(cfg.botToken);
956
- const spawned = await spawnTelegramDaemonOwner(
957
- { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
958
- deps,
959
- );
960
- if (spawned.result === "blocked") {
961
- logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
962
- return "blocked_identity";
2023
+ const root = notificationRootForCwd(input.cwd);
2024
+ const fp = tokenFingerprint(cfg.botToken);
2025
+ let spawned = await withTelegramSetupLease(
2026
+ cfg.botToken,
2027
+ async () =>
2028
+ await spawnTelegramDaemonOwner(
2029
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2030
+ deps,
2031
+ ),
2032
+ );
2033
+ if (spawned.result === "blocked" && spawned.warnings[0]?.includes("provisional")) {
2034
+ const provisional = await readDaemonState(input.settings, deps.fs);
2035
+ // A launcher-reserved PID can be rebound by the child heartbeat. Only a
2036
+ // state marked by bindProvisionalDaemonPid has an authoritative child PID.
2037
+ const hasAuthoritativeChildPid =
2038
+ Number.isSafeInteger(provisional?.launcherPid) && (provisional?.launcherPid as number) > 0;
2039
+ const ready = await waitForTelegramDaemonReady({
2040
+ settings: input.settings,
2041
+ ownerId: provisional?.ownerId,
2042
+ acquisitionId: provisional?.acquisitionId,
2043
+ pid: hasAuthoritativeChildPid ? provisional?.pid : undefined,
2044
+ excludedPid: hasAuthoritativeChildPid ? undefined : provisional?.pid,
2045
+ tokenFingerprint: fp,
2046
+ chatId: cfg.chatId,
2047
+ fs: deps.fs,
2048
+ now: deps.now,
2049
+ pidAlive: deps.pidAlive,
2050
+ pidIncarnation: deps.pidIncarnation,
2051
+ sleep: deps.sleep,
2052
+ waitStepMs: deps.waitStepMs,
2053
+ timeoutMs: deps.readinessTimeoutMs,
2054
+ });
2055
+ if (ready) {
2056
+ spawned = await withTelegramSetupLease(
2057
+ cfg.botToken,
2058
+ async () =>
2059
+ await spawnTelegramDaemonOwner(
2060
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2061
+ deps,
2062
+ ),
2063
+ );
963
2064
  }
964
- if (spawned.reloadRequired) {
965
- await registerNotificationRoot({ ...input, fs: deps.fs });
966
- await reloadStaleGenerationOwner(input.settings, deps);
967
- return "reloaded";
2065
+ }
2066
+ if (spawned.result === "blocked") {
2067
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2068
+ return "blocked_identity";
2069
+ }
2070
+ if (spawned.result === "attached" && spawned.reloadRequired) {
2071
+ const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2072
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2073
+ const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2074
+ const upgrade = await controller.reloadForGenerationUpgrade();
2075
+ if (upgrade.outcome !== "ready") {
2076
+ await restoreNotificationRootRegistration({
2077
+ settings: input.settings,
2078
+ sessionId: input.sessionId,
2079
+ registeredRoot: root,
2080
+ previous,
2081
+ fs: deps.fs,
2082
+ });
2083
+ throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
968
2084
  }
2085
+ return "reloaded";
2086
+ }
2087
+ if (spawned.result !== "owner_spawned") {
969
2088
  await registerNotificationRoot({ ...input, fs: deps.fs });
970
- return spawned.result === "owner_spawned" ? "spawned" : "attached";
971
- });
2089
+ return "attached";
2090
+ }
2091
+ if (
2092
+ await confirmTelegramDaemonSpawn({
2093
+ settings: input.settings,
2094
+ spawned,
2095
+ tokenFingerprint: fp,
2096
+ chatId: cfg.chatId,
2097
+ pid: deps.pid ?? process.pid,
2098
+ fs: deps.fs,
2099
+ now: deps.now,
2100
+ pidAlive: deps.pidAlive,
2101
+ pidIncarnation: deps.pidIncarnation,
2102
+ sleep: deps.sleep,
2103
+ waitStepMs: deps.waitStepMs,
2104
+ timeoutMs: deps.readinessTimeoutMs,
2105
+ })
2106
+ ) {
2107
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2108
+ return "spawned";
2109
+ }
2110
+ throw new Error("Telegram daemon did not become ready after spawning");
972
2111
  }
973
2112
 
974
2113
  /**
@@ -993,26 +2132,21 @@ export async function ensureTelegramDaemonRunning(
993
2132
  }
994
2133
  }
995
2134
 
996
- /**
997
- * Reload a still-live owner running an older daemon generation through the
998
- * cooperative SIGTERM/control handoff. Lazily imports the controller to avoid a
999
- * static import cycle (the controller module imports ownership helpers here).
1000
- */
1001
- async function reloadStaleGenerationOwner(settings: Settings, deps: TelegramDaemonDeps): Promise<void> {
1002
- const { TelegramDaemonController } = await import("./telegram-daemon-control");
1003
- const controller = new TelegramDaemonController(settings, {
2135
+ function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
2136
+ return {
1004
2137
  fs: deps.fs,
1005
2138
  now: deps.now,
1006
2139
  pidAlive: deps.pidAlive,
1007
- sendSignal: deps.sendSignal,
2140
+ processReference: deps.processReference,
2141
+ pidIncarnation: deps.pidIncarnation,
1008
2142
  spawn: deps.spawn,
1009
2143
  execPath: deps.execPath,
1010
2144
  ownerPid: deps.pid,
1011
2145
  randomId: deps.randomId,
1012
2146
  sleep: deps.sleep,
1013
2147
  waitStepMs: deps.waitStepMs,
1014
- });
1015
- await controller.reload();
2148
+ readinessTimeoutMs: deps.readinessTimeoutMs,
2149
+ };
1016
2150
  }
1017
2151
 
1018
2152
  export interface BotApi {
@@ -1293,6 +2427,20 @@ export interface DaemonControlHooks {
1293
2427
  clear?(ownerId: string): Promise<void>;
1294
2428
  }
1295
2429
 
2430
+ type BtwTerminalDeliveryOutcome = "accepted" | "not_delivered" | "uncertain" | "partial_accepted" | "stale";
2431
+
2432
+ interface BtwTerminalDeliveryReceipt {
2433
+ requestId: string;
2434
+ logicalSessionId: string;
2435
+ transportSessionId: string;
2436
+ threadId: string;
2437
+ updateId: number;
2438
+ messageId: number;
2439
+ outcome: BtwTerminalDeliveryOutcome;
2440
+ }
2441
+
2442
+ const BTW_TERMINAL_DELIVERY_TEST_OBSERVER = Symbol.for("gjc.test.btw-terminal-delivery-observer");
2443
+
1296
2444
  export interface TelegramDaemonOptions {
1297
2445
  settings: Settings;
1298
2446
  ownerId: string;
@@ -1313,6 +2461,7 @@ export interface TelegramDaemonOptions {
1313
2461
  pid?: number;
1314
2462
  /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
1315
2463
  pidAlive?: (pid: number) => boolean;
2464
+ pidIncarnation?: (pid: number) => string | undefined;
1316
2465
  botApi?: BotApi;
1317
2466
  control?: DaemonControlHooks;
1318
2467
  /**
@@ -1336,6 +2485,8 @@ export interface TelegramDaemonOptions {
1336
2485
  rich?: { enabled: boolean };
1337
2486
  /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
1338
2487
  richDraft?: { enabled: boolean };
2488
+ /** Tool start/completion messages (enabled by default). */
2489
+ toolActivity?: { enabled: boolean };
1339
2490
  /**
1340
2491
  * Per-session Telegram forum-topic naming. `nameTemplate` supports the
1341
2492
  * `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
@@ -1391,9 +2542,21 @@ interface TopicAuthorityLease {
1391
2542
  authorityEpoch: number;
1392
2543
  }
1393
2544
 
2545
+ interface ToolActivityOwner {
2546
+ sessionId: string;
2547
+ toolCallId: string;
2548
+ toolName: string;
2549
+ endpointDigest: string;
2550
+ session: SessionSocket;
2551
+ phase: "started" | "terminal";
2552
+ policyEpoch?: number;
2553
+ }
2554
+
1394
2555
  interface PendingThreadedFrame {
1395
2556
  send: ThreadedSend;
1396
2557
  msg: Record<string, unknown>;
2558
+ endpointDigest: string;
2559
+ toolActivity?: ToolActivityOwner;
1397
2560
  }
1398
2561
 
1399
2562
  type SelectedAckOutcome =
@@ -1427,6 +2590,7 @@ interface TelegramQueuePayload {
1427
2590
  topicLease?: TopicAuthorityLease;
1428
2591
  selectedAck?: SelectedAckQueueItem;
1429
2592
  btwDelivery?: BtwQueuedDelivery;
2593
+ toolActivity?: ToolActivityOwner;
1430
2594
  }
1431
2595
 
1432
2596
  interface PendingBtwTurn {
@@ -1517,6 +2681,16 @@ export class TelegramNotificationDaemon {
1517
2681
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
1518
2682
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
1519
2683
  private readonly liveMessages = new Map<string, number>();
2684
+ /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
2685
+ private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
2686
+ private readonly revokedToolEndpoints = new Set<string>();
2687
+ private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
2688
+ private toolTerminalizationChain: Promise<void> = Promise.resolve();
2689
+ private toolActivityPolicyEpoch = 0;
2690
+ private toolActivityStopping = false;
2691
+ private readonly replayToolActivityEpochs = new WeakMap<Record<string, unknown>, number>();
2692
+ private toolShutdownBarrier: Promise<void> = Promise.resolve();
2693
+ private toolActivityAmbiguous = false;
1520
2694
  readonly sessions = new Map<string, SessionSocket>();
1521
2695
  /** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
1522
2696
  #modelChoiceAliases = new Map<string, ModelChoiceRoute>();
@@ -1626,7 +2800,13 @@ export class TelegramNotificationDaemon {
1626
2800
  * ~25s getUpdates timeout. Safe to call from a signal handler.
1627
2801
  */
1628
2802
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
1629
- this.effects.beginShutdown();
2803
+ const toolShutdown = this.beginToolActivityShutdown();
2804
+ void toolShutdown
2805
+ .finally(() => {
2806
+ this.effects.beginShutdown();
2807
+ this.#deliveryAbort.abort();
2808
+ })
2809
+ .catch(() => undefined);
1630
2810
  for (const item of new Set(this.selectedAckPending.values())) {
1631
2811
  if (item.state === "queued") this.pool.removeById(item.itemId);
1632
2812
  else item.controller?.abort();
@@ -1637,7 +2817,6 @@ export class TelegramNotificationDaemon {
1637
2817
  delivery.invalidated = true;
1638
2818
  delivery.controller.abort("daemon_shutdown");
1639
2819
  }
1640
- this.#deliveryAbort.abort();
1641
2820
  this.runtime.requestStop();
1642
2821
  this.running = false;
1643
2822
  }
@@ -2444,7 +3623,131 @@ export class TelegramNotificationDaemon {
2444
3623
  * (so a delayed old close cannot delete a replacement), and best-effort closes
2445
3624
  * the socket. `scanRoots()` then reconnects the session.
2446
3625
  */
3626
+ private enqueueToolTerminalization(
3627
+ claimed:
3628
+ | Array<{ messageId: number; owner: ToolActivityOwner }>
3629
+ | (() => Array<{ messageId: number; owner: ToolActivityOwner }>),
3630
+ awaitDispatch: boolean,
3631
+ strict = false,
3632
+ ): Promise<void> {
3633
+ const terminalize = (): Promise<void> =>
3634
+ this.effects.allowTerminal(async () => {
3635
+ if (awaitDispatch) await this.flushChain;
3636
+ const strictFailures: Error[] = [];
3637
+ const claimedItems = typeof claimed === "function" ? claimed() : claimed;
3638
+ for (const item of claimedItems) {
3639
+ const { messageId, owner } = item;
3640
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3641
+ const send = renderThreadedFrame({
3642
+ type: "tool_activity",
3643
+ sessionId: owner.sessionId,
3644
+ toolCallId: owner.toolCallId,
3645
+ toolName: owner.toolName,
3646
+ phase: "unknown",
3647
+ });
3648
+ if (!send?.text) continue;
3649
+ let failure: unknown;
3650
+ let delivered = false;
3651
+ for (let attempt = 0; attempt < (strict ? 5 : 1); attempt++) {
3652
+ try {
3653
+ const response = (await this.botApi.call("editMessageText", {
3654
+ chat_id: this.opts.chatId,
3655
+ message_id: messageId,
3656
+ text: send.text,
3657
+ parse_mode: TELEGRAM_PARSE_MODE,
3658
+ })) as { ok?: boolean; description?: string } | undefined;
3659
+ delivered = response?.ok === true || /not modified/i.test(String(response?.description ?? ""));
3660
+ if (delivered) break;
3661
+ failure = new Error(String(response?.description ?? "Telegram rejected tool terminalization."));
3662
+ } catch (error) {
3663
+ failure = error;
3664
+ }
3665
+ if (strict && attempt < 4) await this.runtime.sleep(50 * 2 ** attempt);
3666
+ }
3667
+ if (delivered) {
3668
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3669
+ continue;
3670
+ }
3671
+ this.unresolvedToolTerminalizations.set(backlogKey, item);
3672
+ if (strict) {
3673
+ const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
3674
+ if (!this.toolActivityOwners.has(key)) {
3675
+ this.toolActivityOwners.set(key, owner);
3676
+ this.liveMessages.set(key, messageId);
3677
+ }
3678
+ strictFailures.push(failure instanceof Error ? failure : new Error("Tool terminalization failed."));
3679
+ }
3680
+ }
3681
+ if (strictFailures.length > 0) {
3682
+ throw new AggregateError(strictFailures, strictFailures.map(failure => failure.message).join("; "));
3683
+ }
3684
+ });
3685
+ const next = this.toolTerminalizationChain.then(terminalize);
3686
+ this.toolTerminalizationChain = next.catch(() => {});
3687
+ return next;
3688
+ }
3689
+
3690
+ private scheduleVisibleToolTerminalization(endpointDigest?: string, strict = false): Promise<void> {
3691
+ if (endpointDigest !== undefined) this.revokedToolEndpoints.add(endpointDigest);
3692
+ const claimVisible = (): Array<{ messageId: number; owner: ToolActivityOwner }> => {
3693
+ const claimedByKey = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
3694
+ for (const [backlogKey, item] of this.unresolvedToolTerminalizations) {
3695
+ if (endpointDigest !== undefined && item.owner.endpointDigest !== endpointDigest) continue;
3696
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3697
+ claimedByKey.set(backlogKey, item);
3698
+ }
3699
+ for (const [key, owner] of this.toolActivityOwners) {
3700
+ if (endpointDigest !== undefined && owner.endpointDigest !== endpointDigest) continue;
3701
+ this.toolActivityOwners.delete(key);
3702
+ const messageId = this.liveMessages.get(key);
3703
+ this.liveMessages.delete(key);
3704
+ if (messageId !== undefined) {
3705
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3706
+ claimedByKey.set(backlogKey, { messageId, owner });
3707
+ }
3708
+ }
3709
+ return [...claimedByKey.values()];
3710
+ };
3711
+ this.pool.removeWhere(
3712
+ item =>
3713
+ item.payload.toolActivity !== undefined &&
3714
+ (endpointDigest === undefined || item.payload.toolActivity.endpointDigest === endpointDigest),
3715
+ );
3716
+ const next = this.enqueueToolTerminalization(claimVisible, false, strict);
3717
+ if (endpointDigest !== undefined) {
3718
+ void next.then(
3719
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3720
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3721
+ );
3722
+ }
3723
+ return next;
3724
+ }
3725
+
3726
+ private beginToolActivityShutdown(): Promise<void> {
3727
+ if (this.toolActivityStopping) return this.toolShutdownBarrier;
3728
+ this.toolActivityStopping = true;
3729
+ this.toolActivityPolicyEpoch++;
3730
+ this.toolShutdownBarrier = (async () => {
3731
+ await this.flushChain;
3732
+ const failures: Error[] = [];
3733
+ if (this.toolActivityAmbiguous) {
3734
+ failures.push(new Error("Tool activity delivery became ambiguous during daemon shutdown."));
3735
+ }
3736
+ try {
3737
+ await this.scheduleVisibleToolTerminalization(undefined, true);
3738
+ } catch (error) {
3739
+ failures.push(error instanceof Error ? error : new Error(String(error)));
3740
+ }
3741
+ if (failures.length > 0) {
3742
+ throw new AggregateError(failures, failures.map(failure => failure.message).join("; "));
3743
+ }
3744
+ })();
3745
+ return this.toolShutdownBarrier;
3746
+ }
3747
+
2447
3748
  private dropSession(session: SessionSocket, reason: string): void {
3749
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
3750
+ if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
2448
3751
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
2449
3752
  if (session.pingTimer) {
2450
3753
  clearIntervalImpl(session.pingTimer);
@@ -2455,7 +3758,6 @@ export class TelegramNotificationDaemon {
2455
3758
  } else {
2456
3759
  void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2457
3760
  }
2458
- const isCurrentSession = this.sessions.get(session.sessionId) === session;
2459
3761
  if (isCurrentSession || reason === "session_closed") {
2460
3762
  this.deleteMessageRoutes(session.sessionId);
2461
3763
  }
@@ -2915,16 +4217,39 @@ export class TelegramNotificationDaemon {
2915
4217
  return !ownerId || ownerId === session.sessionId;
2916
4218
  }
2917
4219
 
4220
+ private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4221
+ if (msg.type !== "tool_activity") return undefined;
4222
+ const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
4223
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
4224
+ const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4225
+ if (!toolCallId || !toolName || !phase) return undefined;
4226
+ return {
4227
+ sessionId: session.sessionId,
4228
+ toolCallId,
4229
+ toolName,
4230
+ endpointDigest: session.endpointDigest,
4231
+ session,
4232
+ phase: phase === "started" ? "started" : "terminal",
4233
+ };
4234
+ }
4235
+
4236
+ private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4237
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4238
+ const session = this.sessions.get(toolActivity.sessionId);
4239
+ if (toolActivity.endpointDigest === undefined) return session === undefined;
4240
+ return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4241
+ }
2918
4242
  private async submitThreadedFrame(
2919
4243
  sessionId: string,
2920
4244
  send: ThreadedSend,
2921
4245
  topicLease: TopicAuthorityLease,
4246
+ toolActivity?: ToolActivityOwner,
2922
4247
  ): Promise<void> {
2923
4248
  this.submitPool({
2924
4249
  sessionId,
2925
4250
  lane: send.lane,
2926
4251
  coalesceKey: send.coalesceKey,
2927
- payload: { send, topicLease },
4252
+ payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
2928
4253
  });
2929
4254
  await this.flushPool();
2930
4255
  }
@@ -2986,18 +4311,33 @@ export class TelegramNotificationDaemon {
2986
4311
  }
2987
4312
  }
2988
4313
 
2989
- private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
2990
- const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
2991
- frames.push({ send, msg });
4314
+ private rememberPendingThreadedFrame(
4315
+ session: SessionSocket,
4316
+ send: ThreadedSend,
4317
+ msg: Record<string, unknown>,
4318
+ toolActivity?: ToolActivityOwner,
4319
+ ): void {
4320
+ const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
4321
+ frames.push({
4322
+ send,
4323
+ msg,
4324
+ endpointDigest: session.endpointDigest,
4325
+ ...(toolActivity ? { toolActivity } : {}),
4326
+ });
2992
4327
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
2993
- this.pendingThreadedFrames.set(sessionId, frames);
4328
+ this.pendingThreadedFrames.set(session.sessionId, frames);
2994
4329
  }
2995
4330
 
2996
4331
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
2997
4332
  const frames = this.pendingThreadedFrames.get(sessionId);
2998
4333
  if (!frames || frames.length === 0) return;
2999
4334
  this.pendingThreadedFrames.delete(sessionId);
3000
- for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
4335
+ const session = this.sessions.get(sessionId);
4336
+ for (const frame of frames) {
4337
+ if (session && session.endpointDigest !== frame.endpointDigest) continue;
4338
+ if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4339
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
4340
+ }
3001
4341
  }
3002
4342
 
3003
4343
  /**
@@ -3082,7 +4422,11 @@ export class TelegramNotificationDaemon {
3082
4422
  })) as { ok?: boolean };
3083
4423
  if (!topicDeleteSettled(res)) return;
3084
4424
  this.topics.settleDelete(sessionId, record.topicId);
3085
- for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
4425
+ for (const k of [...this.liveMessages.keys()])
4426
+ if (k.startsWith(`${sessionId}:`)) {
4427
+ this.liveMessages.delete(k);
4428
+ this.toolActivityOwners.delete(k);
4429
+ }
3086
4430
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
3087
4431
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
3088
4432
  });
@@ -3266,6 +4610,21 @@ export class TelegramNotificationDaemon {
3266
4610
  );
3267
4611
  }
3268
4612
  for (const item of batch) {
4613
+ const toolActivity = item.payload.toolActivity;
4614
+ if (
4615
+ toolActivity?.phase === "started" &&
4616
+ (this.toolActivityStopping ||
4617
+ this.opts.toolActivity?.enabled === false ||
4618
+ toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
4619
+ !this.toolActivityAuthorityIsCurrent(toolActivity))
4620
+ ) {
4621
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
4622
+ const owner = this.toolActivityOwners.get(key);
4623
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
4624
+ this.toolActivityOwners.delete(key);
4625
+ this.pool.settle(item.itemId!, "removed");
4626
+ continue;
4627
+ }
3269
4628
  const btwDelivery = item.payload.btwDelivery;
3270
4629
  if (btwDelivery) {
3271
4630
  if (!btwDelivery.isAuthoritative()) {
@@ -3360,6 +4719,10 @@ export class TelegramNotificationDaemon {
3360
4719
  this.pool.settle(item.itemId!, "rejected");
3361
4720
  continue;
3362
4721
  }
4722
+ if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
4723
+ this.pool.settle(item.itemId!, "removed");
4724
+ continue;
4725
+ }
3363
4726
  // Threaded topic when available; otherwise deliver flat to the paired chat.
3364
4727
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
3365
4728
  const ckey = send.editable ? item.coalesceKey : undefined;
@@ -3558,19 +4921,25 @@ export class TelegramNotificationDaemon {
3558
4921
  }
3559
4922
  }
3560
4923
  if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
3561
- this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
4924
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
3562
4925
  }
3563
4926
  }
3564
4927
  }
3565
4928
  } catch {
3566
4929
  // Best-effort: a failed send/edit must never stop the daemon.
3567
4930
  disposition = "ambiguous";
4931
+ if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
3568
4932
  } finally {
3569
4933
  this.pool.settle(item.itemId!, disposition);
3570
4934
  // A terminal tool frame owns the end of this coalescing key even when both
3571
4935
  // edit and fallback delivery fail. Retaining the old message id would leak
3572
4936
  // one entry per failure and let a later reused key edit stale Telegram state.
3573
- if (send.terminal && editKey) this.liveMessages.delete(editKey);
4937
+ if (send.terminal && editKey) {
4938
+ this.liveMessages.delete(editKey);
4939
+ const owner = this.toolActivityOwners.get(editKey);
4940
+ if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
4941
+ this.toolActivityOwners.delete(editKey);
4942
+ }
3574
4943
  }
3575
4944
  }
3576
4945
  }
@@ -3580,7 +4949,12 @@ export class TelegramNotificationDaemon {
3580
4949
  * so later live/finalized frames edit it in place. Evicts this session's stale
3581
4950
  * same-category entries (e.g. prior turns) so the map stays bounded.
3582
4951
  */
3583
- private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
4952
+ private recordLiveMessage(
4953
+ sessionId: string,
4954
+ coalesceKey: string,
4955
+ messageId: number,
4956
+ toolActivity?: ToolActivityOwner,
4957
+ ): void {
3584
4958
  const mapKey = `${sessionId}:${coalesceKey}`;
3585
4959
  const category = coalesceKey.split(":")[0] ?? "";
3586
4960
  // Single-slot categories (rolling turn/context/reasoning previews) evict prior
@@ -3595,7 +4969,15 @@ export class TelegramNotificationDaemon {
3595
4969
  if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
3596
4970
  }
3597
4971
  }
4972
+ if (toolActivity) {
4973
+ const owner = this.toolActivityOwners.get(mapKey);
4974
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
4975
+ void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
4976
+ return;
4977
+ }
4978
+ }
3598
4979
  this.liveMessages.set(mapKey, messageId);
4980
+ if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
3599
4981
  }
3600
4982
 
3601
4983
  /**
@@ -3607,11 +4989,20 @@ export class TelegramNotificationDaemon {
3607
4989
  * content never lands in a shared chat. Identity headers are sent at most once per
3608
4990
  * session in flat mode.
3609
4991
  */
3610
- private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
4992
+ private async deliverFlatFallback(
4993
+ sessionId: string,
4994
+ send: ThreadedSend,
4995
+ toolActivity?: ToolActivityOwner,
4996
+ ): Promise<void> {
3611
4997
  if (!(await this.pairedChatIsPrivate())) return;
3612
4998
  await this.notifyThreadedFallback();
3613
4999
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
3614
- this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
5000
+ this.submitPool({
5001
+ sessionId,
5002
+ lane: send.lane,
5003
+ coalesceKey: send.coalesceKey,
5004
+ payload: { send, ...(toolActivity ? { toolActivity } : {}) },
5005
+ });
3615
5006
  await this.flushPool();
3616
5007
  if (send.identity) this.flatIdentitySent.add(sessionId);
3617
5008
  }
@@ -3683,11 +5074,13 @@ export class TelegramNotificationDaemon {
3683
5074
  return renewDaemonHeartbeat({
3684
5075
  settings: this.opts.settings,
3685
5076
  ownerId: this.opts.ownerId,
5077
+ acquisitionId: this.opts.ownerId,
3686
5078
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
3687
5079
  chatId: this.opts.chatId,
3688
5080
  fs: this.fsImpl,
3689
5081
  now: this.opts.now,
3690
5082
  pid: this.opts.pid ?? process.pid,
5083
+ pidIncarnation: this.opts.pidIncarnation,
3691
5084
  });
3692
5085
  }
3693
5086
 
@@ -3834,9 +5227,13 @@ export class TelegramNotificationDaemon {
3834
5227
 
3835
5228
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
3836
5229
  if (session.replayPending) {
5230
+ if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5231
+ return;
3837
5232
  const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
3838
5233
  if (!matchingReplay) {
3839
- session.replayQueue.push(msg as Record<string, unknown>);
5234
+ const frame = msg as Record<string, unknown>;
5235
+ if (frame.type === "tool_activity") this.replayToolActivityEpochs.set(frame, this.toolActivityPolicyEpoch);
5236
+ session.replayQueue.push(frame);
3840
5237
  return;
3841
5238
  }
3842
5239
  session.replayPending = false;
@@ -3881,6 +5278,11 @@ export class TelegramNotificationDaemon {
3881
5278
  }
3882
5279
  const queued = session.replayQueue.splice(0);
3883
5280
  for (const frame of queued) {
5281
+ if (
5282
+ frame.type === "tool_activity" &&
5283
+ this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
5284
+ )
5285
+ continue;
3884
5286
  const fingerprint = JSON.stringify(frame);
3885
5287
  const remaining = replayCounts.get(fingerprint) ?? 0;
3886
5288
  if (remaining > 0) {
@@ -3958,6 +5360,7 @@ export class TelegramNotificationDaemon {
3958
5360
  };
3959
5361
  this.#btwTerminalDeliveries.set(requestId, terminalDelivery);
3960
5362
  let deliveryOutcome: "accepted" | "not_delivered" | "uncertain" | "partial_accepted" = "not_delivered";
5363
+ let observerOutcome: BtwTerminalDeliveryOutcome = deliveryOutcome;
3961
5364
  try {
3962
5365
  if (msg.status !== "ok") {
3963
5366
  const text =
@@ -4023,7 +5426,7 @@ export class TelegramNotificationDaemon {
4023
5426
  }
4024
5427
  };
4025
5428
  const html = markdownToTelegramHtml(markdown);
4026
- const fallback = async (): Promise<typeof deliveryOutcome> => {
5429
+ const fallback = async (): Promise<BtwTerminalDeliveryOutcome> => {
4027
5430
  let acceptedChunks = 0;
4028
5431
  for (const [index, text] of splitTelegramHtml(html).entries()) {
4029
5432
  const outcome = await this.#queueBtwFallbackChunk({
@@ -4045,6 +5448,7 @@ export class TelegramNotificationDaemon {
4045
5448
  continue;
4046
5449
  }
4047
5450
  if (outcome === "partial_accepted" || acceptedChunks > 0) return "partial_accepted";
5451
+ if (outcome === "stale") return "stale";
4048
5452
  return outcome === "uncertain" ? "uncertain" : "not_delivered";
4049
5453
  }
4050
5454
  return "accepted";
@@ -4056,16 +5460,21 @@ export class TelegramNotificationDaemon {
4056
5460
  reply_parameters: { message_id: pending.messageId },
4057
5461
  rich_message: { markdown, skip_entity_detection: true },
4058
5462
  });
4059
- deliveryOutcome =
4060
- outcome === "accepted"
4061
- ? "accepted"
4062
- : outcome === "rejected"
4063
- ? await fallback()
4064
- : outcome === "uncertain"
4065
- ? "uncertain"
4066
- : "not_delivered";
5463
+ if (outcome === "accepted") {
5464
+ deliveryOutcome = "accepted";
5465
+ observerOutcome = "accepted";
5466
+ } else if (outcome === "rejected") {
5467
+ const fallbackOutcome = await fallback();
5468
+ observerOutcome = fallbackOutcome;
5469
+ deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
5470
+ } else {
5471
+ deliveryOutcome = outcome === "uncertain" ? "uncertain" : "not_delivered";
5472
+ observerOutcome = outcome === "stale" ? "stale" : deliveryOutcome;
5473
+ }
4067
5474
  } else {
4068
- deliveryOutcome = await fallback();
5475
+ const fallbackOutcome = await fallback();
5476
+ observerOutcome = fallbackOutcome;
5477
+ deliveryOutcome = fallbackOutcome === "stale" ? "not_delivered" : fallbackOutcome;
4069
5478
  }
4070
5479
  } finally {
4071
5480
  if (this.#btwTerminalDeliveries.get(requestId) === terminalDelivery) {
@@ -4084,17 +5493,87 @@ export class TelegramNotificationDaemon {
4084
5493
  }
4085
5494
  } finally {
4086
5495
  terminalDelivery.finish();
5496
+ observerOutcome = observerOutcome === "stale" ? "stale" : deliveryOutcome;
5497
+ if (this.#pendingBtwTurns.get(requestId) !== pending) {
5498
+ try {
5499
+ const observer = (
5500
+ this as unknown as Record<symbol, ((receipt: BtwTerminalDeliveryReceipt) => void) | undefined>
5501
+ )[BTW_TERMINAL_DELIVERY_TEST_OBSERVER];
5502
+ observer?.({
5503
+ requestId,
5504
+ logicalSessionId: pending.logicalSessionId,
5505
+ transportSessionId: pending.transportSessionId,
5506
+ threadId: pending.threadId,
5507
+ updateId: pending.updateId,
5508
+ messageId: pending.messageId,
5509
+ outcome: observerOutcome,
5510
+ });
5511
+ } catch {
5512
+ logger.warn("notifications: /btw terminal delivery observer failed");
5513
+ }
5514
+ }
4087
5515
  }
4088
5516
  }
4089
5517
  return;
4090
5518
  }
4091
5519
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
5520
+ const threadedFrame = msg as Record<string, unknown>;
5521
+ const toolActivity = this.toolActivityOwner(session, threadedFrame);
5522
+ const toolAdmissionEpoch = toolActivity
5523
+ ? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
5524
+ : undefined;
5525
+ if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
5526
+ const toolStartIsCurrent = (): boolean =>
5527
+ toolActivity?.phase !== "started" ||
5528
+ (this.toolActivityAuthorityIsCurrent(toolActivity) &&
5529
+ !this.toolActivityStopping &&
5530
+ this.opts.toolActivity?.enabled !== false &&
5531
+ toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5532
+ const abandonStaleToolStart = (): void => {
5533
+ if (toolActivity?.phase !== "started") return;
5534
+ const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5535
+ const owner = this.toolActivityOwners.get(key);
5536
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5537
+ this.toolActivityOwners.delete(key);
5538
+ };
5539
+ if (toolActivity) {
5540
+ const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5541
+ const currentOwner = this.toolActivityOwners.get(liveKey);
5542
+ if (toolActivity.phase === "started") {
5543
+ if (!toolStartIsCurrent()) return;
5544
+ this.toolActivityOwners.set(liveKey, toolActivity);
5545
+ } else {
5546
+ if (currentOwner && currentOwner.session !== session) return;
5547
+ if (this.opts.toolActivity?.enabled === false) {
5548
+ if (!currentOwner) return;
5549
+ if (!this.liveMessages.has(liveKey)) {
5550
+ // A start may already be granted to the serialized dispatcher but not
5551
+ // yet recorded as visible. Wait for that exact dispatch boundary before
5552
+ // deciding whether its terminal frame must close a visible message.
5553
+ await this.flushChain;
5554
+ }
5555
+ const settledOwner = this.toolActivityOwners.get(liveKey);
5556
+ if (!this.liveMessages.has(liveKey) || settledOwner?.session !== session) {
5557
+ if (settledOwner?.session === session) this.toolActivityOwners.delete(liveKey);
5558
+ return;
5559
+ }
5560
+ }
5561
+ }
5562
+ }
4092
5563
  const send = renderThreadedFrame(msg);
4093
5564
  if (!send) return;
4094
5565
  const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
5566
+ if (!toolStartIsCurrent()) {
5567
+ abandonStaleToolStart();
5568
+ return;
5569
+ }
4095
5570
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4096
5571
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
4097
- this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
5572
+ if (!toolStartIsCurrent()) {
5573
+ abandonStaleToolStart();
5574
+ return;
5575
+ }
5576
+ this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
4098
5577
  return;
4099
5578
  }
4100
5579
  if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
@@ -4108,9 +5587,13 @@ export class TelegramNotificationDaemon {
4108
5587
  const topicId =
4109
5588
  existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
4110
5589
  const topicLease = await this.topicAuthorityLease(session.sessionId);
5590
+ if (!toolStartIsCurrent()) {
5591
+ abandonStaleToolStart();
5592
+ return;
5593
+ }
4111
5594
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
4112
5595
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4113
- await this.deliverFlatFallback(session.sessionId, send);
5596
+ await this.deliverFlatFallback(session.sessionId, send, toolActivity);
4114
5597
  return;
4115
5598
  }
4116
5599
  if (send.identity) {
@@ -4158,7 +5641,11 @@ export class TelegramNotificationDaemon {
4158
5641
  await this.persistTopics();
4159
5642
  return;
4160
5643
  }
4161
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5644
+ if (!toolStartIsCurrent()) {
5645
+ abandonStaleToolStart();
5646
+ return;
5647
+ }
5648
+ await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
4162
5649
  return;
4163
5650
  }
4164
5651
  if (msg.type === "action_needed" && msg.id) {
@@ -4473,28 +5960,32 @@ export class TelegramNotificationDaemon {
4473
5960
  }
4474
5961
  }
4475
5962
  }
4476
- // Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
4477
- // session config forward. Handled at paired-chat pre-routing, before threaded
4478
- // injection and independent of any session WebSocket, so it works even when
4479
- // no session is connected and never becomes an ask answer.
5963
+ // Telegram delivery toggles are daemon-local policy, NOT session config
5964
+ // forwards. Handle them before threaded injection and independently of any
5965
+ // session WebSocket, so they work even when no session is connected.
4480
5966
  {
4481
5967
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
4482
5968
  const chat = m?.chat as { id?: unknown } | undefined;
4483
5969
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
4484
- const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
4485
- // Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
4486
- // appends @botname in groups; the bot username may be unknown if getMe
4487
- // failed) so a rich command is never leaked into threaded injection / an
4488
- // ask answer. Argument validity is decided by parseRichToggleCommand below.
4489
- const isRichCommand = rawFirst?.split("@")[0] === "/rich";
4490
- if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
4491
- // Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
4492
- // paired chat — the same contract as session delivery and lifecycle
4493
- // commands. A group/supergroup chatId (legacy or hand-edited) must never
4494
- // let an arbitrary chat member toggle the owner's notification config.
5970
+ const commandToken = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
5971
+ const [command, commandSuffix] = commandToken?.split("@", 2) ?? [];
5972
+ const isRichCommand = command === "/rich";
5973
+ const isToolActivityCommand = command === "/toolactivity";
5974
+ if (
5975
+ isToolActivityCommand &&
5976
+ commandSuffix &&
5977
+ (!this.botUsername || commandSuffix !== this.botUsername.toLowerCase())
5978
+ )
5979
+ return;
5980
+ if (
5981
+ m !== undefined &&
5982
+ String(chat?.id) === String(this.opts.chatId) &&
5983
+ (isRichCommand || isToolActivityCommand)
5984
+ ) {
5985
+ // These commands mutate global config, so honor them ONLY in the
5986
+ // configured private chat. Fail closed for legacy or hand-edited group IDs.
4495
5987
  if (!(await this.pairedChatIsPrivate())) return;
4496
5988
  const updateId = (update as { update_id?: number }).update_id;
4497
- // Dedupe redelivered updates so a toggle+confirmation runs at most once.
4498
5989
  if (typeof updateId === "number") {
4499
5990
  if (this.dispatchState.seenUpdateIds.has(updateId)) return;
4500
5991
  await this.rememberSeenUpdateId(updateId);
@@ -4513,31 +6004,70 @@ export class TelegramNotificationDaemon {
4513
6004
  // Best-effort confirmation; never block on the notice.
4514
6005
  }
4515
6006
  };
4516
- const desired = parseRichToggleCommand(cmdText ?? "");
6007
+ const desired = isRichCommand
6008
+ ? parseRichToggleCommand(cmdText ?? "")
6009
+ : parseToolActivityToggleCommand(cmdText ?? "", this.botUsername);
6010
+ const usage = isRichCommand ? "Usage: /rich on|off" : "Usage: /toolactivity on|off";
4517
6011
  if (desired === undefined) {
4518
- await reply("Usage: /rich on|off");
6012
+ await reply(usage);
4519
6013
  return;
4520
6014
  }
6015
+ const settingPath = isRichCommand
6016
+ ? "notifications.telegram.rich.enabled"
6017
+ : "notifications.telegram.toolActivity.enabled";
6018
+ const label = isRichCommand ? "Rich messages" : "Tool activity";
4521
6019
  try {
4522
- await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
4523
- // Confirm success only after a DURABLE write. The real Settings.set is
4524
- // a synchronous fire-and-forget whose queued save (Settings.#saveNow)
4525
- // swallows write errors, and Settings.flush() inherits that — neither
4526
- // rejects on a failed config.yml write. flushOrThrow() rethrows the
4527
- // durable-write failure so it lands in the catch below (in-memory
4528
- // isolated Settings short-circuit and never throw). The lightweight
4529
- // daemon settings has no flushOrThrow: its set() already wrote durably
4530
- // (and throws on failure), so its flush() is only a no-op drain.
4531
- await flushRichToggleSettings(this.opts.settings);
6020
+ await this.opts.settings.set(settingPath, desired);
6021
+ // Confirm only after the global config write is durable.
6022
+ await flushTelegramToggleSettings(this.opts.settings);
4532
6023
  } catch (err) {
4533
6024
  logger.warn(
4534
- `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
6025
+ `notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
4535
6026
  );
4536
- await reply("Rich messages: unchanged (settings write failed)");
6027
+ await reply(`${label}: unchanged (settings write failed)`);
4537
6028
  return;
4538
6029
  }
4539
- this.opts.rich = { enabled: desired };
4540
- await reply(desired ? "Rich messages: on" : "Rich messages: off");
6030
+ if (isRichCommand) {
6031
+ this.opts.rich = { enabled: desired };
6032
+ } else {
6033
+ this.toolActivityPolicyEpoch++;
6034
+ this.opts.toolActivity = { enabled: desired };
6035
+ if (!desired) {
6036
+ const removedTools = this.pool.removeWhere(
6037
+ item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
6038
+ );
6039
+ for (const item of removedTools) {
6040
+ const toolActivity = item.payload.toolActivity;
6041
+ if (!toolActivity) continue;
6042
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6043
+ const owner = this.toolActivityOwners.get(key);
6044
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6045
+ this.toolActivityOwners.delete(key);
6046
+ }
6047
+ for (const [sessionId, frames] of this.pendingThreadedFrames) {
6048
+ for (const frame of frames) {
6049
+ const toolActivity = frame.toolActivity;
6050
+ if (!toolActivity || frame.msg.type !== "tool_activity") continue;
6051
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6052
+ const owner = this.toolActivityOwners.get(key);
6053
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6054
+ this.toolActivityOwners.delete(key);
6055
+ }
6056
+ const retained = frames.filter(frame => frame.msg.type !== "tool_activity");
6057
+ if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
6058
+ else this.pendingThreadedFrames.set(sessionId, retained);
6059
+ }
6060
+ for (const session of this.sessions.values()) {
6061
+ session.replayQueue = session.replayQueue.filter(frame => frame.type !== "tool_activity");
6062
+ }
6063
+ }
6064
+ // The policy flips before joining the serialized dispatch chain:
6065
+ // future starts are rejected immediately, queued starts are removed,
6066
+ // and any already-granted Bot API effect settles before the off
6067
+ // acknowledgement. Visible starts may still receive their terminal edit.
6068
+ await this.flushChain;
6069
+ }
6070
+ await reply(`${label}: ${desired ? "on" : "off"}`);
4541
6071
  return;
4542
6072
  }
4543
6073
  }
@@ -4855,6 +6385,10 @@ export class TelegramNotificationDaemon {
4855
6385
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
4856
6386
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
4857
6387
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
6388
+ {
6389
+ command: "toolactivity",
6390
+ description: "Toggle tool activity updates: /toolactivity <on|off>",
6391
+ },
4858
6392
  { command: "reasoning", description: "Show or change reasoning effort in this session" },
4859
6393
  { command: "usage", description: "Show provider/local usage for this session" },
4860
6394
  { command: "model", description: "Select a model for this session" },
@@ -4883,11 +6417,13 @@ export class TelegramNotificationDaemon {
4883
6417
  this.running = await renewDaemonHeartbeat({
4884
6418
  settings: this.opts.settings,
4885
6419
  ownerId: this.opts.ownerId,
6420
+ acquisitionId: this.opts.ownerId,
4886
6421
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
4887
6422
  chatId: this.opts.chatId,
4888
6423
  fs: this.fsImpl,
4889
6424
  now: this.opts.now,
4890
6425
  pid: this.opts.pid ?? process.pid,
6426
+ pidIncarnation: this.opts.pidIncarnation,
4891
6427
  });
4892
6428
  if (!this.running) return;
4893
6429
  this.runtime.start();
@@ -4909,7 +6445,20 @@ export class TelegramNotificationDaemon {
4909
6445
  let idleSince = this.runtime.now();
4910
6446
  while (this.running) {
4911
6447
  if (await this.controlStopRequested()) break;
4912
- if (!(await this.renewOwnershipHeartbeat())) break;
6448
+ if (
6449
+ !(await renewDaemonHeartbeat({
6450
+ settings: this.opts.settings,
6451
+ ownerId: this.opts.ownerId,
6452
+ acquisitionId: this.opts.ownerId,
6453
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
6454
+ chatId: this.opts.chatId,
6455
+ fs: this.fsImpl,
6456
+ now: this.opts.now,
6457
+ pid: this.opts.pid ?? process.pid,
6458
+ pidIncarnation: this.opts.pidIncarnation,
6459
+ }))
6460
+ )
6461
+ break;
4913
6462
  await this.runScan();
4914
6463
  if (await this.controlStopRequested()) break;
4915
6464
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -4944,10 +6493,19 @@ export class TelegramNotificationDaemon {
4944
6493
  await this.runtime.sleep(10);
4945
6494
  }
4946
6495
  } finally {
6496
+ let toolShutdownError: unknown;
6497
+ try {
6498
+ await this.beginToolActivityShutdown();
6499
+ } catch (error) {
6500
+ toolShutdownError = error;
6501
+ }
4947
6502
  this.effects.beginShutdown();
6503
+ this.#deliveryAbort.abort();
4948
6504
  let persisted = false;
4949
6505
  const shutdown = this.effects.allowTerminal(async () => {
6506
+ if (toolShutdownError) throw toolShutdownError;
4950
6507
  await this.#drainBtwTurns();
6508
+ await this.toolTerminalizationChain;
4951
6509
  this.runtime.stop();
4952
6510
  this.stopOwnershipHeartbeatTimer();
4953
6511
  this.stopFlushTimer();
@@ -4981,9 +6539,12 @@ export class TelegramNotificationDaemon {
4981
6539
  await releaseDaemonOwnership({
4982
6540
  settings: this.opts.settings,
4983
6541
  ownerId: this.opts.ownerId,
6542
+ acquisitionId: this.opts.ownerId,
4984
6543
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
4985
6544
  chatId: this.opts.chatId,
4986
6545
  pid: this.opts.pid ?? process.pid,
6546
+ generation: DAEMON_GENERATION,
6547
+ pidIncarnation: this.opts.pidIncarnation,
4987
6548
  fs: this.fsImpl,
4988
6549
  now: this.opts.now,
4989
6550
  });