@gajae-code/coding-agent 0.11.4 → 0.11.6

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 (154) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +31 -1
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +98 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. package/src/tools/subagent.ts +2 -0
@@ -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,245 @@ 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
+ * v0.11.4 and earlier generations wrote stopped tombstones before acquisition
870
+ * and incarnation fields existed. A live, reused PID must not turn that durable
871
+ * stop marker into a foreign live owner during an upgrade.
872
+ */
873
+ function isLegacyStoppedDaemonState(state: unknown): boolean {
874
+ const candidate = state as Partial<DaemonState> | undefined;
875
+ return Boolean(
876
+ candidate &&
877
+ Number.isSafeInteger(candidate.pid) &&
878
+ (candidate.pid ?? 0) > 0 &&
879
+ typeof candidate.ownerId === "string" &&
880
+ candidate.ownerId.length > 0 &&
881
+ typeof candidate.tokenFingerprint === "string" &&
882
+ typeof candidate.chatId === "string" &&
883
+ Number.isSafeInteger(candidate.startedAt) &&
884
+ Number.isSafeInteger(candidate.heartbeatAt) &&
885
+ Number.isSafeInteger(candidate.stoppedAt) &&
886
+ (candidate.launcherPid === undefined ||
887
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid ?? 0) > 0)) &&
888
+ Array.isArray(candidate.roots) &&
889
+ candidate.roots.every(root => typeof root === "string") &&
890
+ candidate.version === DAEMON_VERSION &&
891
+ isRecognizedLegacyGeneration(candidate.generation) &&
892
+ candidate.incarnation === undefined &&
893
+ candidate.acquisitionId === undefined &&
894
+ candidate.ownershipPhase === undefined,
895
+ );
896
+ }
897
+
898
+ function isStoppedDaemonState(state: unknown): boolean {
899
+ return isExplicitlyStoppedDaemonState(state) || isLegacyStoppedDaemonState(state);
900
+ }
901
+
902
+ function ownerIdentityMatches(
903
+ state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
904
+ tokenFingerprint: string,
905
+ chatId: string,
906
+ ): boolean {
528
907
  return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
529
908
  }
530
909
 
531
- function liveOwnerUsesDifferentIdentity(input: {
910
+ function ownerProvenanceMatches(state: DaemonState, pidIncarnation?: (pid: number) => string | undefined): boolean {
911
+ const current = (pidIncarnation ?? defaultPidIncarnation)(state.pid);
912
+ return isProcessIncarnation(state.incarnation) && isProcessIncarnation(current) && current === state.incarnation;
913
+ }
914
+
915
+ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
916
+ if (!state || typeof state !== "object" || Array.isArray(state)) return false;
917
+ const candidate = state as Partial<DaemonState>;
918
+ return Boolean(
919
+ Number.isSafeInteger(candidate.pid) &&
920
+ (candidate.pid as number) > 0 &&
921
+ typeof candidate.ownerId === "string" &&
922
+ candidate.ownerId.length > 0 &&
923
+ (candidate.acquisitionId === undefined ||
924
+ (typeof candidate.acquisitionId === "string" && candidate.acquisitionId.length > 0)) &&
925
+ (candidate.ownershipPhase === undefined ||
926
+ candidate.ownershipPhase === "provisional" ||
927
+ candidate.ownershipPhase === "ready" ||
928
+ candidate.ownershipPhase === "retired") &&
929
+ typeof candidate.tokenFingerprint === "string" &&
930
+ typeof candidate.chatId === "string" &&
931
+ Number.isSafeInteger(candidate.startedAt) &&
932
+ Number.isSafeInteger(candidate.heartbeatAt) &&
933
+ isProcessIncarnation(candidate.incarnation) &&
934
+ (candidate.launcherPid === undefined ||
935
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid as number) > 0)) &&
936
+ Array.isArray(candidate.roots) &&
937
+ candidate.roots.every(root => typeof root === "string") &&
938
+ candidate.version === DAEMON_VERSION &&
939
+ (candidate.generation === undefined ||
940
+ (Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
941
+ (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
942
+ );
943
+ }
944
+
945
+ type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
946
+ incarnation?: undefined;
947
+ acquisitionId?: undefined;
948
+ ownershipPhase?: undefined;
949
+ generation?: undefined;
950
+ };
951
+
952
+ interface LegacyMigrationAttestation {
953
+ pid: number;
954
+ incarnation: string;
955
+ heartbeatAt: number;
956
+ observedAt: number;
957
+ }
958
+
959
+ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
960
+ const candidate = state as Partial<LegacyParentDaemonState> | undefined;
961
+ return Boolean(
962
+ candidate &&
963
+ Number.isSafeInteger(candidate.pid) &&
964
+ (candidate.pid ?? 0) > 0 &&
965
+ typeof candidate.ownerId === "string" &&
966
+ candidate.ownerId.length > 0 &&
967
+ typeof candidate.tokenFingerprint === "string" &&
968
+ typeof candidate.chatId === "string" &&
969
+ Number.isSafeInteger(candidate.startedAt) &&
970
+ Number.isSafeInteger(candidate.heartbeatAt) &&
971
+ Array.isArray(candidate.roots) &&
972
+ candidate.roots.every(root => typeof root === "string") &&
973
+ candidate.version === DAEMON_VERSION &&
974
+ candidate.incarnation === undefined &&
975
+ candidate.acquisitionId === undefined &&
976
+ candidate.ownershipPhase === undefined &&
977
+ candidate.generation === undefined &&
978
+ candidate.stoppedAt === undefined,
979
+ );
980
+ }
981
+
982
+ function legacyMigrationAttestationPath(statePath: string): string {
983
+ return `${statePath}.legacy-migration.json`;
984
+ }
985
+
986
+ async function legacyParentHandoffDecision(input: {
987
+ fs: TelegramDaemonFs;
988
+ statePath: string;
989
+ state: LegacyParentDaemonState;
990
+ now: number;
991
+ pidAlive: (pid: number) => boolean;
992
+ pidIncarnation: (pid: number) => string | undefined;
993
+ tokenFingerprint: string;
994
+ chatId: string;
995
+ }): Promise<
996
+ { acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
997
+ > {
998
+ const { state } = input;
999
+ if (!input.pidAlive(state.pid)) return undefined;
1000
+ if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1001
+ return { acquired: false, attached: false, blocked: true };
1002
+ const incarnation = input.pidIncarnation(state.pid);
1003
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
1004
+ const attestationPath = legacyMigrationAttestationPath(input.statePath);
1005
+ const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
1006
+ const attested = Boolean(
1007
+ previous &&
1008
+ Number.isSafeInteger(previous.pid) &&
1009
+ isProcessIncarnation(previous.incarnation) &&
1010
+ Number.isSafeInteger(previous.heartbeatAt) &&
1011
+ Number.isSafeInteger(previous.observedAt) &&
1012
+ previous.pid === state.pid &&
1013
+ previous.incarnation === incarnation &&
1014
+ state.heartbeatAt > previous.heartbeatAt &&
1015
+ input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
1016
+ );
1017
+ await writeJsonAtomic(input.fs, attestationPath, {
1018
+ pid: state.pid,
1019
+ incarnation,
1020
+ heartbeatAt: state.heartbeatAt,
1021
+ observedAt: input.now,
1022
+ } satisfies LegacyMigrationAttestation);
1023
+ return attested
1024
+ ? { acquired: false, attached: false, reloadRequired: true }
1025
+ : { acquired: false, attached: false, provisional: true };
1026
+ }
1027
+
1028
+ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
1029
+ return (
1030
+ generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
1031
+ );
1032
+ }
1033
+
1034
+ /**
1035
+ * Classifies a physically live daemon record before any replacement action.
1036
+ *
1037
+ * A canonical, differing incarnation is the only authoritative proof that a
1038
+ * live PID is no longer the recorded owner. This applies equally to matching
1039
+ * and foreign Telegram identities: identity never substitutes for provenance.
1040
+ */
1041
+ function classifyForeignLiveOwner(input: {
1042
+ state: unknown;
1043
+ tokenFingerprint: string;
1044
+ chatId: string;
1045
+ pidAlive: (pid: number) => boolean;
1046
+ pidIncarnation?: (pid: number) => string | undefined;
1047
+ }): "identity_mismatch" | "ambiguous" | undefined {
1048
+ const state = input.state as Partial<DaemonState> | undefined;
1049
+ // Validate before probing so malformed PIDs never reach the liveness source.
1050
+ if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1051
+ // A stopped tombstone with a canonical acquisition is not a live owner.
1052
+ if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1053
+ const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1054
+ if (
1055
+ !hasSafeDaemonStateShape(state) ||
1056
+ !isProcessIncarnation(state.incarnation) ||
1057
+ !isProcessIncarnation(currentIncarnation)
1058
+ )
1059
+ return "ambiguous";
1060
+ if (currentIncarnation !== state.incarnation) return undefined;
1061
+ return ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ? undefined : "identity_mismatch";
1062
+ }
1063
+
1064
+ /** True for a physically live owner with this configuration, including legacy generations. */
1065
+ export function isPhysicalMatchingOwner(input: {
532
1066
  state: DaemonState | undefined;
533
1067
  tokenFingerprint: string;
534
1068
  chatId: string;
535
1069
  pidAlive: (pid: number) => boolean;
1070
+ pidIncarnation?: (pid: number) => string | undefined;
536
1071
  }): boolean {
537
1072
  const { state } = input;
538
1073
  return Boolean(
539
1074
  state &&
540
- state.version === DAEMON_VERSION &&
1075
+ hasSafeDaemonStateShape(state) &&
541
1076
  state.stoppedAt === undefined &&
542
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
543
- validDaemonPid(state.pid) &&
544
- input.pidAlive(state.pid),
1077
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1078
+ input.pidAlive(state.pid) &&
1079
+ ownerProvenanceMatches(state, input.pidIncarnation),
1080
+ );
1081
+ }
1082
+
1083
+ /** True only for a signalable legacy owner or this generation's ready bound child. */
1084
+ export function isSignalableMatchingOwner(input: {
1085
+ state: DaemonState | undefined;
1086
+ tokenFingerprint: string;
1087
+ chatId: string;
1088
+ pidAlive: (pid: number) => boolean;
1089
+ pidIncarnation?: (pid: number) => string | undefined;
1090
+ }): boolean {
1091
+ const { state } = input;
1092
+ return Boolean(
1093
+ isPhysicalMatchingOwner(input) &&
1094
+ (isRecognizedLegacyGeneration(state?.generation) ||
1095
+ (state?.generation === DAEMON_GENERATION &&
1096
+ state.ownershipPhase === "ready" &&
1097
+ typeof state.acquisitionId === "string" &&
1098
+ state.acquisitionId.length > 0)),
545
1099
  );
546
1100
  }
547
1101
 
@@ -551,16 +1105,37 @@ export function isFreshLiveOwner(input: {
551
1105
  tokenFingerprint: string;
552
1106
  chatId: string;
553
1107
  pidAlive: (pid: number) => boolean;
1108
+ pidIncarnation?: (pid: number) => string | undefined;
554
1109
  }): boolean {
555
1110
  const { state } = input;
556
1111
  return Boolean(
557
1112
  state &&
558
- state.version === DAEMON_VERSION &&
1113
+ hasSafeDaemonStateShape(state) &&
559
1114
  state.stoppedAt === undefined &&
560
1115
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
561
1116
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
562
- validDaemonPid(state.pid) &&
563
- input.pidAlive(state.pid),
1117
+ input.pidAlive(state.pid) &&
1118
+ ownerProvenanceMatches(state, input.pidIncarnation),
1119
+ );
1120
+ }
1121
+
1122
+ /** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */
1123
+ export function isCurrentCompatibleOwner(input: {
1124
+ state: DaemonState | undefined;
1125
+ now: number;
1126
+ tokenFingerprint: string;
1127
+ chatId: string;
1128
+ pidAlive: (pid: number) => boolean;
1129
+ pidIncarnation?: (pid: number) => string | undefined;
1130
+ }): boolean {
1131
+ const state = input.state;
1132
+ return Boolean(
1133
+ isFreshLiveOwner(input) &&
1134
+ state?.ownershipPhase === "ready" &&
1135
+ typeof state.acquisitionId === "string" &&
1136
+ state.acquisitionId.length > 0 &&
1137
+ Number.isSafeInteger(state.generation) &&
1138
+ (state.generation as number) >= DAEMON_GENERATION,
564
1139
  );
565
1140
  }
566
1141
 
@@ -573,6 +1148,7 @@ export async function acquireDaemonOwnership(input: {
573
1148
  now?: () => number;
574
1149
  pid?: number;
575
1150
  pidAlive?: (pid: number) => boolean;
1151
+ pidIncarnation?: (pid: number) => string | undefined;
576
1152
  randomId?: () => string;
577
1153
  /** Permit one Windows source launcher PID to daemon PID handoff. */
578
1154
  allowPidRebind?: boolean;
@@ -581,8 +1157,10 @@ export async function acquireDaemonOwnership(input: {
581
1157
  }): Promise<{
582
1158
  acquired: boolean;
583
1159
  ownerId?: string;
1160
+ acquisitionId?: string;
584
1161
  attached?: boolean;
585
1162
  blocked?: boolean;
1163
+ provisional?: boolean;
586
1164
  reason?: "identity_mismatch";
587
1165
  reloadRequired?: boolean;
588
1166
  }> {
@@ -591,190 +1169,672 @@ export async function acquireDaemonOwnership(input: {
591
1169
  const pid = input.pid ?? process.pid;
592
1170
  if (!validDaemonPid(pid)) return { acquired: false, attached: true };
593
1171
  const pidAlive = input.pidAlive ?? defaultPidAlive;
1172
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1173
+ const incarnation = pidIncarnation(pid);
1174
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, provisional: true };
594
1175
  const paths = daemonPaths(input.settings.getAgentDir());
595
1176
  await ensureDir(fsImpl, paths.dir);
596
1177
  const ownerId =
597
1178
  input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
598
1179
  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;
1180
+
1181
+ // A fresh, identity-matching live owner running an OLDER generation than this
1182
+ // build cannot serve our newer wire frames; signal a reload instead of a
1183
+ // silent attach. Newer/equal generations attach as before (no downgrade).
1184
+ const attachDecision = (
1185
+ state: DaemonState | undefined,
1186
+ ):
1187
+ | { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
1188
+ | undefined => {
1189
+ if (state && !hasSafeDaemonStateShape(state)) {
1190
+ const malformed = state as Partial<DaemonState>;
610
1191
  if (
611
- !validDaemonPid(initialization.pid) ||
612
- typeof initialization.startedAt !== "number" ||
613
- !Number.isFinite(initialization.startedAt)
1192
+ !isStoppedDaemonState(malformed) &&
1193
+ validDaemonPid(malformed.pid) &&
1194
+ typeof malformed.incarnation === "string" &&
1195
+ pidAlive(malformed.pid)
614
1196
  )
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,
1197
+ return { acquired: false, attached: false, blocked: true };
1198
+ return undefined;
1199
+ }
1200
+ if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1201
+ return undefined;
1202
+ // Unavailable provenance is ambiguous and must remain fail-closed. A
1203
+ // different authoritative incarnation proves PID reuse, so allow only the
1204
+ // transition-locked path below to reclaim the stale owner artifacts.
1205
+ if (pidAlive(state.pid)) {
1206
+ const currentIncarnation = pidIncarnation(state.pid);
1207
+ if (!isProcessIncarnation(currentIncarnation) || !isProcessIncarnation(state.incarnation))
1208
+ return { acquired: false, attached: false, blocked: true };
1209
+ if (currentIncarnation !== state.incarnation) return undefined;
1210
+ }
1211
+ if (!pidAlive(state.pid)) return undefined;
1212
+ if (
1213
+ isCurrentCompatibleOwner({
1214
+ state,
1215
+ now: now(),
628
1216
  tokenFingerprint: input.tokenFingerprint,
629
1217
  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 };
1218
+ pidAlive,
1219
+ pidIncarnation,
1220
+ })
1221
+ )
1222
+ return { acquired: false, attached: true };
1223
+ // A physical owner that cannot prove current compatibility is never safe to
1224
+ // attach. Legacy, stale, and malformed generations require a handoff;
1225
+ // otherwise retain the explicit provisional classification.
1226
+ if (
1227
+ state.version !== DAEMON_VERSION ||
1228
+ !isFreshLiveOwner({
1229
+ state,
1230
+ now: now(),
1231
+ tokenFingerprint: input.tokenFingerprint,
1232
+ chatId: input.chatId,
1233
+ pidAlive,
1234
+ pidIncarnation,
1235
+ }) ||
1236
+ isRecognizedLegacyGeneration(state.generation)
1237
+ )
1238
+ return { acquired: false, attached: false, reloadRequired: true };
1239
+ return { acquired: false, attached: false, provisional: true };
1240
+ };
1241
+ const existing = await readJson<DaemonState>(fsImpl, paths.state);
1242
+ const foreignOwner = classifyForeignLiveOwner({
1243
+ state: existing,
1244
+ tokenFingerprint: input.tokenFingerprint,
1245
+ chatId: input.chatId,
1246
+ pidAlive,
1247
+ pidIncarnation,
1248
+ });
1249
+ if (foreignOwner) {
1250
+ return foreignOwner === "identity_mismatch"
1251
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1252
+ : { acquired: false, attached: false, blocked: true };
1253
+ }
1254
+
1255
+ const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1256
+ if (!transition) return { acquired: false, attached: false, provisional: true };
1257
+ try {
1258
+ const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
1259
+ const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1260
+ const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
1261
+ if (
1262
+ recheckedDecision &&
1263
+ (recheckedDecision.attached ||
1264
+ recheckedDecision.blocked ||
1265
+ recheckedLock.kind === "missing" ||
1266
+ ownershipLockMatchesState(recheckedLock, rechecked))
1267
+ )
1268
+ return recheckedDecision;
1269
+ // A stopped tombstone authorizes removal only of its own canonical lock.
1270
+ // A newer initializer may have replaced the pathname while that tombstone
1271
+ // remained, and must receive the same liveness/freshness protection as any
1272
+ // other live reservation.
1273
+ const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
1274
+ const lockDecision = stoppedLockMatches
1275
+ ? undefined
1276
+ : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1277
+ if (lockDecision) return lockDecision;
1278
+ if (
1279
+ !stoppedLockMatches &&
1280
+ !(await ownershipLockIsReclaimable({
1281
+ fs: fsImpl,
1282
+ path: paths.lock,
1283
+ lock: recheckedLock,
1284
+ now: now(),
1285
+ pidAlive,
1286
+ pidIncarnation,
1287
+ }))
1288
+ )
1289
+ return { acquired: false, attached: false, provisional: true };
1290
+ if (isLegacyParentDaemonState(rechecked)) {
1291
+ const legacyDecision = await legacyParentHandoffDecision({
1292
+ fs: fsImpl,
1293
+ statePath: paths.state,
1294
+ state: rechecked,
1295
+ now: now(),
1296
+ pidAlive,
1297
+ pidIncarnation,
1298
+ tokenFingerprint: input.tokenFingerprint,
1299
+ chatId: input.chatId,
1300
+ });
1301
+ if (legacyDecision) return legacyDecision;
1302
+ } else if (recheckedDecision) {
1303
+ return recheckedDecision;
1304
+ }
1305
+ const recheckedForeignOwner = classifyForeignLiveOwner({
1306
+ state: rechecked,
1307
+ tokenFingerprint: input.tokenFingerprint,
1308
+ chatId: input.chatId,
1309
+ pidAlive,
1310
+ pidIncarnation,
1311
+ });
1312
+ if (recheckedForeignOwner) {
1313
+ return recheckedForeignOwner === "identity_mismatch"
1314
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1315
+ : { acquired: false, attached: false, blocked: true };
1316
+ }
1317
+ if (
1318
+ hasSafeDaemonStateShape(rechecked) &&
1319
+ rechecked.stoppedAt === undefined &&
1320
+ pidAlive(rechecked.pid) &&
1321
+ ownerProvenanceMatches(rechecked, pidIncarnation)
1322
+ )
1323
+ return { acquired: false, attached: false, provisional: true };
1324
+ const currentLock = await readOwnershipLock(fsImpl, paths.lock);
1325
+ if (!ownershipLockMatches(recheckedLock, currentLock))
1326
+ return { acquired: false, attached: false, provisional: true };
1327
+ if (currentLock.kind !== "missing") {
1328
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1329
+ return { acquired: false, attached: false, provisional: true };
1330
+ await fsImpl.unlink(paths.lock);
1331
+ }
1332
+ const ownershipLock: OwnershipLockMetadata = {
1333
+ pid,
1334
+ incarnation,
1335
+ ownerId,
1336
+ acquisitionId: ownerId,
1337
+ startedAt: now(),
655
1338
  };
656
- const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
657
- liveOwnerUsesDifferentIdentity({
1339
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1340
+ return { acquired: false, attached: false, provisional: true };
1341
+ if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, ownershipLock)))
1342
+ return { acquired: false, attached: false, provisional: true };
1343
+ if (
1344
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1345
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1346
+ )
1347
+ return { acquired: false, attached: false, provisional: true };
1348
+ await writeJsonAtomic(fsImpl, paths.state, {
1349
+ pid,
1350
+ incarnation,
1351
+ ownerId,
1352
+ acquisitionId: ownerId,
1353
+ ownershipPhase: "provisional",
1354
+ tokenFingerprint: input.tokenFingerprint,
1355
+ chatId: input.chatId,
1356
+ startedAt: now(),
1357
+ heartbeatAt: now(),
1358
+ roots,
1359
+ version: DAEMON_VERSION,
1360
+ generation: DAEMON_GENERATION,
1361
+ } satisfies DaemonState);
1362
+ if (
1363
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1364
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1365
+ )
1366
+ return { acquired: false, attached: false, provisional: true };
1367
+ return { acquired: true, ownerId, acquisitionId: ownerId };
1368
+ } finally {
1369
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1370
+ }
1371
+ }
1372
+
1373
+ export async function renewDaemonHeartbeat(input: {
1374
+ settings: Settings;
1375
+ ownerId: string;
1376
+ acquisitionId?: string;
1377
+ tokenFingerprint?: string;
1378
+ chatId?: string;
1379
+ fs?: TelegramDaemonFs;
1380
+ now?: () => number;
1381
+ pid?: number;
1382
+ generation?: number;
1383
+ pidIncarnation?: (pid: number) => string | undefined;
1384
+ sleep?: (ms: number) => Promise<void>;
1385
+ stealRetries?: number;
1386
+ stealRetryDelayMs?: number;
1387
+ }): Promise<boolean> {
1388
+ const fsImpl = input.fs ?? nodeFs;
1389
+ const paths = daemonPaths(input.settings.getAgentDir());
1390
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1391
+ // The steal lock is held only briefly by concurrent lifecycle operations.
1392
+ // A contended lock never proves readiness: only the holder may validate and
1393
+ // publish the exact ready PID/generation state.
1394
+ const transition = await acquireTransitionLock({
1395
+ fs: fsImpl,
1396
+ path: paths.steal,
1397
+ pidIncarnation: input.pidIncarnation,
1398
+ sleep: input.sleep,
1399
+ retries: input.stealRetries,
1400
+ retryDelayMs: input.stealRetryDelayMs,
1401
+ });
1402
+ if (!transition) return false;
1403
+ try {
1404
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1405
+ const pid = input.pid ?? state?.pid;
1406
+ const generation = input.generation ?? state?.generation;
1407
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
1408
+ // A daemon child may atomically bind its own PID only while its launcher
1409
+ // reservation is still provisional and carries the same acquisition secret.
1410
+ const canBindProvisionalPid =
1411
+ state?.ownershipPhase === "provisional" &&
1412
+ state.pid !== pid &&
1413
+ state.ownerId === input.ownerId &&
1414
+ state.acquisitionId === acquisitionId;
1415
+ if (
1416
+ !state ||
1417
+ !hasSafeDaemonStateShape(state) ||
1418
+ typeof pid !== "number" ||
1419
+ !Number.isSafeInteger(pid) ||
1420
+ pid <= 0 ||
1421
+ !isProcessIncarnation(incarnation) ||
1422
+ generation !== DAEMON_GENERATION ||
1423
+ state.ownerId !== input.ownerId ||
1424
+ state.acquisitionId !== acquisitionId ||
1425
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1426
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1427
+ (!canBindProvisionalPid && state.incarnation !== incarnation) ||
1428
+ (!canBindProvisionalPid && state.pid !== pid) ||
1429
+ state.generation !== generation ||
1430
+ state.stoppedAt !== undefined ||
1431
+ state.ownershipPhase === "retired"
1432
+ )
1433
+ return false;
1434
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1435
+ const previousLock = canBindProvisionalPid ? await readOwnershipLock(fsImpl, paths.lock) : undefined;
1436
+ const expectedLock =
1437
+ previousLock?.kind === "valid" &&
1438
+ previousLock.metadata.pid === state.pid &&
1439
+ previousLock.metadata.incarnation === state.incarnation &&
1440
+ previousLock.metadata.ownerId === state.ownerId &&
1441
+ previousLock.metadata.acquisitionId === state.acquisitionId
1442
+ ? previousLock.metadata
1443
+ : undefined;
1444
+ const reboundLock = expectedLock ? { ...expectedLock, pid, incarnation } : undefined;
1445
+ if (
1446
+ canBindProvisionalPid &&
1447
+ (!expectedLock ||
1448
+ !reboundLock ||
1449
+ !(await rebindOwnershipLock({
1450
+ fs: fsImpl,
1451
+ path: paths.lock,
1452
+ transitionPath: paths.steal,
1453
+ transition,
1454
+
1455
+ expected: expectedLock,
1456
+ rebound: reboundLock,
1457
+ })))
1458
+ )
1459
+ return false;
1460
+ try {
1461
+ await writeJsonAtomic(fsImpl, paths.state, {
1462
+ ...state,
1463
+ // Preserve the source launcher PID so concurrent ensures can recognize
1464
+ // this ready PID as its child rather than excluding it as the launcher.
1465
+ launcherPid: state.pid,
1466
+ pid,
1467
+ incarnation,
1468
+ ownershipPhase: "ready",
1469
+ heartbeatAt: (input.now ?? Date.now)(),
1470
+ });
1471
+ } catch {
1472
+ if (expectedLock && reboundLock)
1473
+ await rollbackOwnershipLockRebind({
1474
+ fs: fsImpl,
1475
+ path: paths.lock,
1476
+ transitionPath: paths.steal,
1477
+ transition,
1478
+
1479
+ previous: expectedLock,
1480
+ rebound: reboundLock,
1481
+ });
1482
+ return false;
1483
+ }
1484
+ return true;
1485
+ } finally {
1486
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1487
+ }
1488
+ }
1489
+
1490
+ /** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */
1491
+ export async function acquireTransitionLock(input: {
1492
+ fs: TelegramDaemonFs;
1493
+ path: string;
1494
+ pidAlive?: (pid: number) => boolean;
1495
+ pidIncarnation?: (pid: number) => string | undefined;
1496
+ sleep?: (ms: number) => Promise<void>;
1497
+ retries?: number;
1498
+ retryDelayMs?: number;
1499
+ }): Promise<DaemonTransitionLock | undefined> {
1500
+ return await acquireDaemonTransitionLock({
1501
+ fs: input.fs,
1502
+ path: input.path,
1503
+ pid: process.pid,
1504
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1505
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1506
+ sleep: input.sleep,
1507
+ retries: input.retries,
1508
+ retryDelayMs: input.retryDelayMs,
1509
+ });
1510
+ }
1511
+
1512
+ /** Retire only the unchanged provisional acquisition after bounded readiness fails. */
1513
+ export async function retireProvisionalDaemonOwnership(input: {
1514
+ settings: Settings;
1515
+ ownerId: string;
1516
+ acquisitionId?: string;
1517
+ pidIncarnation?: (pid: number) => string | undefined;
1518
+ pidAlive?: (pid: number) => boolean;
1519
+ /** Detached child PID, when the launcher successfully reported it. */
1520
+ pid: number;
1521
+ /** PID which created the provisional reservation before the child was bound. */
1522
+ launcherPid?: number;
1523
+ fs?: TelegramDaemonFs;
1524
+ now?: () => number;
1525
+ sleep?: (ms: number) => Promise<void>;
1526
+ stealRetries?: number;
1527
+ stealRetryDelayMs?: number;
1528
+ /** Only no-child confirmation may retire a ready-like launcher publication. */
1529
+ allowReadyWithoutChildPid?: boolean;
1530
+ }): Promise<boolean> {
1531
+ const fsImpl = input.fs ?? nodeFs;
1532
+ const paths = daemonPaths(input.settings.getAgentDir());
1533
+ const transition = await acquireTransitionLock({
1534
+ fs: fsImpl,
1535
+ path: paths.steal,
1536
+ pidAlive: input.pidAlive,
1537
+ pidIncarnation: input.pidIncarnation,
1538
+ sleep: input.sleep,
1539
+ retries: input.stealRetries,
1540
+ retryDelayMs: input.stealRetryDelayMs,
1541
+ });
1542
+ if (!transition) return false;
1543
+ try {
1544
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1545
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1546
+ const expectedPid = state?.pid === input.pid ? input.pid : input.launcherPid;
1547
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1548
+ const expectedPidAlive = expectedPid !== undefined && pidAlive(expectedPid);
1549
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(expectedPid ?? 0);
1550
+ if (
1551
+ !state ||
1552
+ state.ownerId !== input.ownerId ||
1553
+ state.acquisitionId !== acquisitionId ||
1554
+ (expectedPidAlive &&
1555
+ (!isProcessIncarnation(incarnation) ||
1556
+ !isProcessIncarnation(state.incarnation) ||
1557
+ state.incarnation !== incarnation)) ||
1558
+ (state.pid !== input.pid && state.pid !== input.launcherPid) ||
1559
+ state.generation !== DAEMON_GENERATION ||
1560
+ !Number.isSafeInteger(state.generation) ||
1561
+ (state.ownershipPhase !== "provisional" &&
1562
+ !(input.allowReadyWithoutChildPid === true && state.ownershipPhase === "ready"))
1563
+ )
1564
+ return false;
1565
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1566
+ await writeJsonAtomic(fsImpl, paths.state, {
1567
+ ...state,
1568
+ ownershipPhase: "retired",
1569
+ stoppedAt: (input.now ?? Date.now)(),
1570
+ });
1571
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1572
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
1573
+ return true;
1574
+ } finally {
1575
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1576
+ }
1577
+ }
1578
+
1579
+ /** Bind a launcher-reserved provisional acquisition to its actual detached child. */
1580
+ async function bindProvisionalDaemonPid(input: {
1581
+ settings: Settings;
1582
+ ownerId: string;
1583
+ acquisitionId: string;
1584
+ pid: number;
1585
+ incarnation: string;
1586
+ fs?: TelegramDaemonFs;
1587
+ sleep?: (ms: number) => Promise<void>;
1588
+ stealRetries?: number;
1589
+ stealRetryDelayMs?: number;
1590
+ }): Promise<boolean> {
1591
+ const fsImpl = input.fs ?? nodeFs;
1592
+ const paths = daemonPaths(input.settings.getAgentDir());
1593
+ const transition = await acquireTransitionLock({
1594
+ fs: fsImpl,
1595
+ path: paths.steal,
1596
+ sleep: input.sleep,
1597
+ retries: input.stealRetries,
1598
+ retryDelayMs: input.stealRetryDelayMs,
1599
+ });
1600
+ if (!transition) return false;
1601
+ try {
1602
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1603
+ if (
1604
+ !state ||
1605
+ state.ownerId !== input.ownerId ||
1606
+ state.acquisitionId !== input.acquisitionId ||
1607
+ state.ownershipPhase !== "provisional" ||
1608
+ state.generation !== DAEMON_GENERATION ||
1609
+ !isProcessIncarnation(state.incarnation) ||
1610
+ !isProcessIncarnation(input.incarnation)
1611
+ )
1612
+ return false;
1613
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1614
+ const previousLock = await readOwnershipLock(fsImpl, paths.lock);
1615
+ if (
1616
+ previousLock.kind !== "valid" ||
1617
+ previousLock.metadata.pid !== state.pid ||
1618
+ previousLock.metadata.incarnation !== state.incarnation ||
1619
+ previousLock.metadata.ownerId !== state.ownerId ||
1620
+ previousLock.metadata.acquisitionId !== state.acquisitionId
1621
+ )
1622
+ return false;
1623
+ const reboundLock = { ...previousLock.metadata, pid: input.pid, incarnation: input.incarnation };
1624
+ if (
1625
+ !(await rebindOwnershipLock({
1626
+ fs: fsImpl,
1627
+ path: paths.lock,
1628
+ transitionPath: paths.steal,
1629
+ transition,
1630
+
1631
+ expected: previousLock.metadata,
1632
+ rebound: reboundLock,
1633
+ }))
1634
+ )
1635
+ return false;
1636
+ try {
1637
+ await writeJsonAtomic(fsImpl, paths.state, {
1638
+ ...state,
1639
+ // This durable marker distinguishes a launcher reservation from a PID
1640
+ // the launcher authoritatively rebound to its child.
1641
+ launcherPid: state.launcherPid ?? state.pid,
1642
+ pid: input.pid,
1643
+ incarnation: input.incarnation,
1644
+ });
1645
+ } catch {
1646
+ await rollbackOwnershipLockRebind({
1647
+ fs: fsImpl,
1648
+ path: paths.lock,
1649
+ transitionPath: paths.steal,
1650
+ transition,
1651
+ previous: previousLock.metadata,
1652
+ rebound: reboundLock,
1653
+ });
1654
+ return false;
1655
+ }
1656
+ return true;
1657
+ } finally {
1658
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1659
+ }
1660
+ }
1661
+
1662
+ /** Wait for a matching current-generation daemon to publish a ready state. */
1663
+ export async function waitForTelegramDaemonReady(input: {
1664
+ settings: Settings;
1665
+ ownerId?: string;
1666
+ acquisitionId?: string;
1667
+ pid?: number;
1668
+ excludedPid?: number;
1669
+ tokenFingerprint: string;
1670
+ chatId: string;
1671
+ fs?: TelegramDaemonFs;
1672
+ now?: () => number;
1673
+ pidAlive?: (pid: number) => boolean;
1674
+ pidIncarnation?: (pid: number) => string | undefined;
1675
+ sleep?: (ms: number) => Promise<void>;
1676
+ waitStepMs?: number;
1677
+ timeoutMs?: number;
1678
+ }): Promise<boolean> {
1679
+ const now = input.now ?? Date.now;
1680
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1681
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1682
+ const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
1683
+ const timeoutMs = Math.max(input.timeoutMs ?? 8_000, 0);
1684
+ const waitStepMs = Math.max(input.waitStepMs ?? 25, 1);
1685
+ const deadline = now() + timeoutMs;
1686
+ const maxPolls = Math.ceil(timeoutMs / waitStepMs);
1687
+ for (let poll = 0; poll <= maxPolls; poll++) {
1688
+ const state = await readDaemonState(input.settings, input.fs);
1689
+ if (
1690
+ (!input.ownerId || state?.ownerId === input.ownerId) &&
1691
+ (!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
1692
+ state?.ownershipPhase === "ready" &&
1693
+ state.generation === DAEMON_GENERATION &&
1694
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1695
+ ownerProvenanceMatches(state, pidIncarnation) &&
1696
+ pidAlive(state.pid) &&
1697
+ (Number.isSafeInteger(input.pid) && (input.pid as number) > 0
1698
+ ? state.pid === input.pid
1699
+ : !Number.isSafeInteger(input.excludedPid) || state.pid !== input.excludedPid) &&
1700
+ isCurrentCompatibleOwner({
658
1701
  state,
1702
+ now: now(),
659
1703
  tokenFingerprint: input.tokenFingerprint,
660
1704
  chatId: input.chatId,
661
1705
  pidAlive,
662
- });
663
- const existing = await readJson<DaemonState>(fsImpl, paths.state);
664
- if (rejectLiveDifferentIdentity(existing)) {
665
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
666
- }
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 };
673
- }
674
- const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
675
- if (rejectLiveDifferentIdentity(afterLock)) {
676
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
677
- }
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 };
682
- 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);
711
- }
712
- });
1706
+ pidIncarnation,
1707
+ })
1708
+ )
1709
+ return true;
1710
+ if (now() >= deadline || poll === maxPolls) break;
1711
+ await sleep(waitStepMs);
1712
+ }
1713
+ return false;
713
1714
  }
714
1715
 
715
- export async function renewDaemonHeartbeat(input: {
1716
+ /** Confirm the provisional owner or retire only its unchanged acquisition. */
1717
+ export async function confirmTelegramDaemonSpawn(input: {
716
1718
  settings: Settings;
717
- ownerId: string;
718
- /** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
1719
+ spawned: TelegramSpawnOwnerResult;
719
1720
  tokenFingerprint: string;
720
1721
  chatId: string;
721
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
722
1722
  pid: number;
723
1723
  fs?: TelegramDaemonFs;
724
1724
  now?: () => number;
1725
+ pidAlive?: (pid: number) => boolean;
1726
+ pidIncarnation?: (pid: number) => string | undefined;
1727
+ sleep?: (ms: number) => Promise<void>;
1728
+ waitStepMs?: number;
1729
+ timeoutMs?: number;
725
1730
  }): Promise<boolean> {
726
- const fsImpl = input.fs ?? nodeFs;
727
- const paths = daemonPaths(input.settings.getAgentDir());
728
- return withFileLock(paths.ownership, async () => {
729
- const state = await readJson<DaemonState>(fsImpl, paths.state);
730
- if (
731
- !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
- state.ownerId !== input.ownerId ||
738
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId)
739
- )
740
- return false;
741
- if (!validDaemonPid(state.pid)) return false;
742
- if (state.pid !== input.pid && (!validDaemonPid(state.launcherPid) || state.pid !== state.launcherPid))
743
- return false;
744
- await writeJsonAtomic(fsImpl, paths.state, {
745
- ...state,
746
- pid: input.pid,
747
- heartbeatAt: (input.now ?? Date.now)(),
748
- });
749
- return true;
1731
+ if (input.spawned.result !== "owner_spawned") return true;
1732
+ const childPid = input.spawned.acquisition.pid;
1733
+ const hasExactChildPid = Number.isSafeInteger(childPid) && (childPid as number) > 0;
1734
+ const ready = await waitForTelegramDaemonReady({
1735
+ settings: input.settings,
1736
+ ownerId: input.spawned.acquisition.ownerId,
1737
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1738
+ pid: hasExactChildPid ? childPid : undefined,
1739
+ excludedPid: hasExactChildPid ? undefined : input.pid,
1740
+ tokenFingerprint: input.tokenFingerprint,
1741
+ chatId: input.chatId,
1742
+ fs: input.fs,
1743
+ now: input.now,
1744
+ pidAlive: input.pidAlive,
1745
+ pidIncarnation: input.pidIncarnation,
1746
+ sleep: input.sleep,
1747
+ waitStepMs: input.waitStepMs,
1748
+ timeoutMs: input.timeoutMs,
1749
+ });
1750
+ if (ready) return true;
1751
+ const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
1752
+ const retired = await retireProvisionalDaemonOwnership({
1753
+ settings: input.settings,
1754
+ ownerId: input.spawned.acquisition.ownerId,
1755
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1756
+ pid: hasExactChildPid ? (childPid as number) : launcherPid,
1757
+ launcherPid,
1758
+ allowReadyWithoutChildPid: !hasExactChildPid,
1759
+ fs: input.fs,
1760
+ now: input.now,
1761
+ pidAlive: input.pidAlive,
1762
+ pidIncarnation: input.pidIncarnation,
1763
+ sleep: input.sleep,
750
1764
  });
1765
+ if (retired) return false;
1766
+ const state = await readDaemonState(input.settings, input.fs);
1767
+ if (
1768
+ hasExactChildPid &&
1769
+ isCurrentCompatibleOwner({
1770
+ state,
1771
+ now: (input.now ?? Date.now)(),
1772
+ tokenFingerprint: input.tokenFingerprint,
1773
+ chatId: input.chatId,
1774
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1775
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1776
+ }) &&
1777
+ (state?.ownerId !== input.spawned.acquisition.ownerId ||
1778
+ state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
1779
+ state.pid === childPid)
1780
+ )
1781
+ return true;
1782
+ if (
1783
+ state?.ownerId === input.spawned.acquisition.ownerId &&
1784
+ state.acquisitionId === input.spawned.acquisition.acquisitionId &&
1785
+ (state.pid === childPid || state.pid === launcherPid || state.pid === input.pid) &&
1786
+ state.ownershipPhase === "retired"
1787
+ )
1788
+ return false;
1789
+ throw new Error("Telegram daemon provisional ownership could not be retired safely");
751
1790
  }
752
1791
 
753
1792
  export async function releaseDaemonOwnership(input: {
754
1793
  settings: Settings;
755
1794
  ownerId: string;
756
- /** Bind release to the Telegram identity that acquired the lock. */
757
- tokenFingerprint: string;
758
- chatId: string;
759
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
760
- pid: number;
1795
+ acquisitionId?: string;
1796
+ tokenFingerprint?: string;
1797
+ chatId?: string;
1798
+ pid?: number;
1799
+ generation?: number;
1800
+ pidIncarnation?: (pid: number) => string | undefined;
761
1801
  fs?: TelegramDaemonFs;
762
1802
  now?: () => number;
763
1803
  }): Promise<void> {
764
1804
  const fsImpl = input.fs ?? nodeFs;
765
1805
  const paths = daemonPaths(input.settings.getAgentDir());
766
- await withFileLock(paths.ownership, async () => {
1806
+ const transition = await acquireTransitionLock({
1807
+ fs: fsImpl,
1808
+ path: paths.steal,
1809
+ pidIncarnation: input.pidIncarnation,
1810
+ });
1811
+ if (!transition) return;
1812
+ try {
767
1813
  const state = await readJson<DaemonState>(fsImpl, paths.state);
1814
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1815
+ const pid = input.pid ?? state?.pid;
1816
+ const generation = input.generation ?? state?.generation;
1817
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
768
1818
  if (
769
- !state ||
1819
+ !hasSafeDaemonStateShape(state) ||
770
1820
  state.ownerId !== input.ownerId ||
771
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
772
- state.pid !== input.pid
1821
+ state.acquisitionId !== acquisitionId ||
1822
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1823
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1824
+ state.pid !== pid ||
1825
+ !isProcessIncarnation(incarnation) ||
1826
+ state.incarnation !== incarnation ||
1827
+ state.generation !== generation ||
1828
+ !Number.isSafeInteger(generation)
773
1829
  )
774
1830
  return;
1831
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
775
1832
  await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
776
- await fsImpl.unlink(paths.lock).catch(() => undefined);
777
- });
1833
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1834
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
1835
+ } finally {
1836
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1837
+ }
778
1838
  }
779
1839
 
780
1840
  /** Read the persisted daemon ownership state (or undefined when absent). */
@@ -803,6 +1863,15 @@ function defaultPidAlive(pid: number): boolean {
803
1863
  }
804
1864
  }
805
1865
 
1866
+ /**
1867
+ * Use immutable process-start provenance on every supported OS. In particular,
1868
+ * Windows is authorized by the native process API or PowerShell StartTime/FileTime;
1869
+ * absent authority fails closed and never trusts the numeric PID alone.
1870
+ */
1871
+ function defaultPidIncarnation(pid: number): string | undefined {
1872
+ return processIncarnation(pid);
1873
+ }
1874
+
806
1875
  /** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
807
1876
  function isAbortError(err: unknown): boolean {
808
1877
  return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
@@ -828,7 +1897,7 @@ function defaultDaemonSpawn(
828
1897
  const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
829
1898
  // Best-effort autostart: a spawn failure must never crash the host session.
830
1899
  child.on("error", () => undefined);
831
- return { unref: () => child.unref() };
1900
+ return { pid: child.pid, unref: () => child.unref() };
832
1901
  }
833
1902
 
834
1903
  export interface TelegramSpawnOwnerInput {
@@ -838,19 +1907,20 @@ export interface TelegramSpawnOwnerInput {
838
1907
  chatId: string;
839
1908
  }
840
1909
 
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;
1910
+ export interface TelegramSpawnAcquisition {
1911
+ readonly ownerId: string;
1912
+ readonly acquisitionId: string;
1913
+ /** PID of the launcher which reserved provisional ownership before spawn. */
1914
+ readonly launcherPid?: number;
1915
+ /** Actual detached child which must publish the ready owner state. */
1916
+ readonly pid?: number;
852
1917
  }
853
1918
 
1919
+ export type TelegramSpawnOwnerResult =
1920
+ | { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
1921
+ | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
1922
+ | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
1923
+
854
1924
  /**
855
1925
  * Build the detached spawn command/args for the daemon-internal entrypoint.
856
1926
  * Source mode prepends the entry script so the respawn loads edited source;
@@ -910,26 +1980,41 @@ export async function spawnTelegramDaemonOwner(
910
1980
  now: deps.now,
911
1981
  pid: deps.pid,
912
1982
  pidAlive: deps.pidAlive,
1983
+ pidIncarnation: deps.pidIncarnation,
913
1984
  randomId: ownerId ? undefined : deps.randomId,
914
1985
  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
1986
  });
923
1987
  if (!ownership.acquired) {
924
- if (ownership.blocked) {
1988
+ if (ownership.blocked || ownership.provisional) {
925
1989
  return {
926
1990
  result: "blocked",
927
- runtime,
928
- warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
1991
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
1992
+ warnings: [
1993
+ ownership.provisional
1994
+ ? "telegram daemon ownership is provisional; refusing to attach"
1995
+ : "live telegram daemon uses a different bot token or chat; refusing to attach",
1996
+ ],
929
1997
  };
930
1998
  }
931
- return { result: "attached", runtime, warnings: [], reloadRequired: ownership.reloadRequired };
1999
+ return {
2000
+ result: "attached",
2001
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
2002
+ warnings: [],
2003
+ reloadRequired: ownership.reloadRequired,
2004
+ };
932
2005
  }
2006
+ const launcherPid = deps.pid ?? process.pid;
2007
+ const provisionalAcquisition: TelegramSpawnAcquisition = Object.freeze({
2008
+ ownerId: ownership.ownerId as string,
2009
+ acquisitionId: ownership.acquisitionId as string,
2010
+ launcherPid,
2011
+ });
2012
+ // One source of truth for runtime detection + spawn args (no duplicate resolve).
2013
+ const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
2014
+ execPath,
2015
+ ownerId: provisionalAcquisition.ownerId,
2016
+ agentDir,
2017
+ });
933
2018
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
934
2019
  const child = spawnImpl(command, args, {
935
2020
  detached: true,
@@ -937,7 +2022,79 @@ export async function spawnTelegramDaemonOwner(
937
2022
  logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
938
2023
  });
939
2024
  child?.unref?.();
940
- return { result: "owner_spawned", ownerId: ownership.ownerId, runtime, warnings: [] };
2025
+ // A launcher can reserve ownership, but only the actual child can become
2026
+ // ready. Missing PID provenance is intentionally non-ready and times out.
2027
+ const childPid = child?.pid;
2028
+ if (!Number.isSafeInteger(childPid) || (childPid as number) <= 0)
2029
+ return { result: "owner_spawned", acquisition: provisionalAcquisition, runtime, warnings: [] };
2030
+ const pid = childPid as number;
2031
+ const acquisition: TelegramSpawnAcquisition = Object.freeze({ ...provisionalAcquisition, pid });
2032
+ const incarnation = (deps.pidIncarnation ?? defaultPidIncarnation)(pid);
2033
+ if (!incarnation) return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2034
+ await bindProvisionalDaemonPid({
2035
+ settings: input.settings,
2036
+ ownerId: acquisition.ownerId,
2037
+ acquisitionId: acquisition.acquisitionId,
2038
+ incarnation,
2039
+ pid,
2040
+ fs: deps.fs,
2041
+ sleep: deps.sleep,
2042
+ });
2043
+ return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2044
+ }
2045
+
2046
+ /**
2047
+ * Owner-bound reclamation of a confirmed-dead daemon owner's lock, mirroring the
2048
+ * daemon step of `gjc notify recovery`. Removes only the lock of a recorded owner
2049
+ * that is still the same, still-dead process — never a live or superseded owner,
2050
+ * because it re-checks ownership and liveness while holding the same steal-mutex
2051
+ * the daemon's own takeover path uses. Lets a fresh SDK session self-heal a stale
2052
+ * owner (e.g. after a crash, or a `stop --force` that left the record behind)
2053
+ * instead of requiring manual reconciliation. Returns true when a stale lock was
2054
+ * cleared and a spawn retry is worthwhile.
2055
+ */
2056
+ async function reclaimDeadDaemonOwner(input: {
2057
+ settings: Settings;
2058
+ fs?: TelegramDaemonFs;
2059
+ now?: () => number;
2060
+ pidAlive?: (pid: number) => boolean;
2061
+ pidIncarnation?: (pid: number) => string | undefined;
2062
+ }): Promise<boolean> {
2063
+ const fsImpl = input.fs ?? nodeFs;
2064
+ const now = input.now ?? Date.now;
2065
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
2066
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
2067
+ const paths = daemonPaths(input.settings.getAgentDir());
2068
+ const state = await readDaemonState(input.settings, fsImpl);
2069
+ if (!state || pidAlive(state.pid)) return false;
2070
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2071
+ if (lock.kind === "missing") return false;
2072
+ if (
2073
+ !(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
2074
+ )
2075
+ return false;
2076
+ const transition = await acquireDaemonTransitionLock({
2077
+ fs: fsImpl,
2078
+ path: paths.steal,
2079
+ pid: process.pid,
2080
+ pidAlive: defaultPidAlive,
2081
+ pidIncarnation: defaultPidIncarnation,
2082
+ });
2083
+ if (!transition) return false;
2084
+ try {
2085
+ const current = await readDaemonState(input.settings, fsImpl);
2086
+ if (!current || current.ownerId !== state.ownerId || current.pid !== state.pid) return false;
2087
+ if (pidAlive(current.pid)) return false;
2088
+ if (!(await daemonTransitionLockIsHeld({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2089
+ try {
2090
+ await fsImpl.unlink(paths.lock);
2091
+ return true;
2092
+ } catch {
2093
+ return false;
2094
+ }
2095
+ } finally {
2096
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2097
+ }
941
2098
  }
942
2099
 
943
2100
  /**
@@ -950,25 +2107,117 @@ export async function ensureTelegramDaemonRunningDetailed(
950
2107
  ): Promise<EnsureTelegramDaemonDetailedResult> {
951
2108
  const cfg = getNotificationConfig(input.settings);
952
2109
  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";
2110
+ const root = notificationRootForCwd(input.cwd);
2111
+ const fp = tokenFingerprint(cfg.botToken);
2112
+ let spawned = await withTelegramSetupLease(
2113
+ cfg.botToken,
2114
+ async () =>
2115
+ await spawnTelegramDaemonOwner(
2116
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2117
+ deps,
2118
+ ),
2119
+ );
2120
+ if (spawned.result === "blocked" && spawned.warnings[0]?.includes("provisional")) {
2121
+ const provisional = await readDaemonState(input.settings, deps.fs);
2122
+ // A launcher-reserved PID can be rebound by the child heartbeat. Only a
2123
+ // state marked by bindProvisionalDaemonPid has an authoritative child PID.
2124
+ const hasAuthoritativeChildPid =
2125
+ Number.isSafeInteger(provisional?.launcherPid) && (provisional?.launcherPid as number) > 0;
2126
+ const ready = await waitForTelegramDaemonReady({
2127
+ settings: input.settings,
2128
+ ownerId: provisional?.ownerId,
2129
+ acquisitionId: provisional?.acquisitionId,
2130
+ pid: hasAuthoritativeChildPid ? provisional?.pid : undefined,
2131
+ excludedPid: hasAuthoritativeChildPid ? undefined : provisional?.pid,
2132
+ tokenFingerprint: fp,
2133
+ chatId: cfg.chatId,
2134
+ fs: deps.fs,
2135
+ now: deps.now,
2136
+ pidAlive: deps.pidAlive,
2137
+ pidIncarnation: deps.pidIncarnation,
2138
+ sleep: deps.sleep,
2139
+ waitStepMs: deps.waitStepMs,
2140
+ timeoutMs: deps.readinessTimeoutMs,
2141
+ });
2142
+ if (ready) {
2143
+ spawned = await withTelegramSetupLease(
2144
+ cfg.botToken,
2145
+ async () =>
2146
+ await spawnTelegramDaemonOwner(
2147
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2148
+ deps,
2149
+ ),
2150
+ );
963
2151
  }
964
- if (spawned.reloadRequired) {
965
- await registerNotificationRoot({ ...input, fs: deps.fs });
966
- await reloadStaleGenerationOwner(input.settings, deps);
967
- return "reloaded";
2152
+ }
2153
+ if (
2154
+ spawned.result === "blocked" &&
2155
+ !spawned.warnings[0]?.includes("provisional") &&
2156
+ (await reclaimDeadDaemonOwner({
2157
+ settings: input.settings,
2158
+ fs: deps.fs,
2159
+ now: deps.now,
2160
+ pidAlive: deps.pidAlive,
2161
+ pidIncarnation: deps.pidIncarnation,
2162
+ }))
2163
+ ) {
2164
+ // A crashed owner (or a `stop --force` that found no live process) can leave a
2165
+ // stale lock behind that blocks re-acquisition. Automate the recovery step so a
2166
+ // fresh session self-heals instead of requiring a manual `gjc notify recovery`.
2167
+ spawned = await withTelegramSetupLease(
2168
+ cfg.botToken,
2169
+ async () =>
2170
+ await spawnTelegramDaemonOwner(
2171
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2172
+ deps,
2173
+ ),
2174
+ );
2175
+ }
2176
+ if (spawned.result === "blocked") {
2177
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2178
+ return "blocked_identity";
2179
+ }
2180
+ if (spawned.result === "attached" && spawned.reloadRequired) {
2181
+ const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2182
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2183
+ const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2184
+ const upgrade = await controller.reloadForGenerationUpgrade();
2185
+ if (upgrade.outcome !== "ready") {
2186
+ await restoreNotificationRootRegistration({
2187
+ settings: input.settings,
2188
+ sessionId: input.sessionId,
2189
+ registeredRoot: root,
2190
+ previous,
2191
+ fs: deps.fs,
2192
+ });
2193
+ throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
968
2194
  }
2195
+ return "reloaded";
2196
+ }
2197
+ if (spawned.result !== "owner_spawned") {
969
2198
  await registerNotificationRoot({ ...input, fs: deps.fs });
970
- return spawned.result === "owner_spawned" ? "spawned" : "attached";
971
- });
2199
+ return "attached";
2200
+ }
2201
+ if (
2202
+ await confirmTelegramDaemonSpawn({
2203
+ settings: input.settings,
2204
+ spawned,
2205
+ tokenFingerprint: fp,
2206
+ chatId: cfg.chatId,
2207
+ pid: deps.pid ?? process.pid,
2208
+ fs: deps.fs,
2209
+ now: deps.now,
2210
+ pidAlive: deps.pidAlive,
2211
+ pidIncarnation: deps.pidIncarnation,
2212
+ sleep: deps.sleep,
2213
+ waitStepMs: deps.waitStepMs,
2214
+ timeoutMs: deps.readinessTimeoutMs,
2215
+ })
2216
+ ) {
2217
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2218
+ return "spawned";
2219
+ }
2220
+ throw new Error("Telegram daemon did not become ready after spawning");
972
2221
  }
973
2222
 
974
2223
  /**
@@ -993,26 +2242,21 @@ export async function ensureTelegramDaemonRunning(
993
2242
  }
994
2243
  }
995
2244
 
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, {
2245
+ function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
2246
+ return {
1004
2247
  fs: deps.fs,
1005
2248
  now: deps.now,
1006
2249
  pidAlive: deps.pidAlive,
1007
- sendSignal: deps.sendSignal,
2250
+ processReference: deps.processReference,
2251
+ pidIncarnation: deps.pidIncarnation,
1008
2252
  spawn: deps.spawn,
1009
2253
  execPath: deps.execPath,
1010
2254
  ownerPid: deps.pid,
1011
2255
  randomId: deps.randomId,
1012
2256
  sleep: deps.sleep,
1013
2257
  waitStepMs: deps.waitStepMs,
1014
- });
1015
- await controller.reload();
2258
+ readinessTimeoutMs: deps.readinessTimeoutMs,
2259
+ };
1016
2260
  }
1017
2261
 
1018
2262
  export interface BotApi {
@@ -1327,6 +2571,7 @@ export interface TelegramDaemonOptions {
1327
2571
  pid?: number;
1328
2572
  /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
1329
2573
  pidAlive?: (pid: number) => boolean;
2574
+ pidIncarnation?: (pid: number) => string | undefined;
1330
2575
  botApi?: BotApi;
1331
2576
  control?: DaemonControlHooks;
1332
2577
  /**
@@ -1350,6 +2595,8 @@ export interface TelegramDaemonOptions {
1350
2595
  rich?: { enabled: boolean };
1351
2596
  /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
1352
2597
  richDraft?: { enabled: boolean };
2598
+ /** Tool start/completion messages (enabled by default). */
2599
+ toolActivity?: { enabled: boolean };
1353
2600
  /**
1354
2601
  * Per-session Telegram forum-topic naming. `nameTemplate` supports the
1355
2602
  * `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
@@ -1405,9 +2652,21 @@ interface TopicAuthorityLease {
1405
2652
  authorityEpoch: number;
1406
2653
  }
1407
2654
 
2655
+ interface ToolActivityOwner {
2656
+ sessionId: string;
2657
+ toolCallId: string;
2658
+ toolName: string;
2659
+ endpointDigest: string;
2660
+ session: SessionSocket;
2661
+ phase: "started" | "terminal";
2662
+ policyEpoch?: number;
2663
+ }
2664
+
1408
2665
  interface PendingThreadedFrame {
1409
2666
  send: ThreadedSend;
1410
2667
  msg: Record<string, unknown>;
2668
+ endpointDigest: string;
2669
+ toolActivity?: ToolActivityOwner;
1411
2670
  }
1412
2671
 
1413
2672
  type SelectedAckOutcome =
@@ -1441,6 +2700,7 @@ interface TelegramQueuePayload {
1441
2700
  topicLease?: TopicAuthorityLease;
1442
2701
  selectedAck?: SelectedAckQueueItem;
1443
2702
  btwDelivery?: BtwQueuedDelivery;
2703
+ toolActivity?: ToolActivityOwner;
1444
2704
  }
1445
2705
 
1446
2706
  interface PendingBtwTurn {
@@ -1531,6 +2791,16 @@ export class TelegramNotificationDaemon {
1531
2791
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
1532
2792
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
1533
2793
  private readonly liveMessages = new Map<string, number>();
2794
+ /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
2795
+ private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
2796
+ private readonly revokedToolEndpoints = new Set<string>();
2797
+ private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
2798
+ private toolTerminalizationChain: Promise<void> = Promise.resolve();
2799
+ private toolActivityPolicyEpoch = 0;
2800
+ private toolActivityStopping = false;
2801
+ private readonly replayToolActivityEpochs = new WeakMap<Record<string, unknown>, number>();
2802
+ private toolShutdownBarrier: Promise<void> = Promise.resolve();
2803
+ private toolActivityAmbiguous = false;
1534
2804
  readonly sessions = new Map<string, SessionSocket>();
1535
2805
  /** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
1536
2806
  #modelChoiceAliases = new Map<string, ModelChoiceRoute>();
@@ -1640,7 +2910,13 @@ export class TelegramNotificationDaemon {
1640
2910
  * ~25s getUpdates timeout. Safe to call from a signal handler.
1641
2911
  */
1642
2912
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
1643
- this.effects.beginShutdown();
2913
+ const toolShutdown = this.beginToolActivityShutdown();
2914
+ void toolShutdown
2915
+ .finally(() => {
2916
+ this.effects.beginShutdown();
2917
+ this.#deliveryAbort.abort();
2918
+ })
2919
+ .catch(() => undefined);
1644
2920
  for (const item of new Set(this.selectedAckPending.values())) {
1645
2921
  if (item.state === "queued") this.pool.removeById(item.itemId);
1646
2922
  else item.controller?.abort();
@@ -1651,7 +2927,6 @@ export class TelegramNotificationDaemon {
1651
2927
  delivery.invalidated = true;
1652
2928
  delivery.controller.abort("daemon_shutdown");
1653
2929
  }
1654
- this.#deliveryAbort.abort();
1655
2930
  this.runtime.requestStop();
1656
2931
  this.running = false;
1657
2932
  }
@@ -2458,7 +3733,131 @@ export class TelegramNotificationDaemon {
2458
3733
  * (so a delayed old close cannot delete a replacement), and best-effort closes
2459
3734
  * the socket. `scanRoots()` then reconnects the session.
2460
3735
  */
3736
+ private enqueueToolTerminalization(
3737
+ claimed:
3738
+ | Array<{ messageId: number; owner: ToolActivityOwner }>
3739
+ | (() => Array<{ messageId: number; owner: ToolActivityOwner }>),
3740
+ awaitDispatch: boolean,
3741
+ strict = false,
3742
+ ): Promise<void> {
3743
+ const terminalize = (): Promise<void> =>
3744
+ this.effects.allowTerminal(async () => {
3745
+ if (awaitDispatch) await this.flushChain;
3746
+ const strictFailures: Error[] = [];
3747
+ const claimedItems = typeof claimed === "function" ? claimed() : claimed;
3748
+ for (const item of claimedItems) {
3749
+ const { messageId, owner } = item;
3750
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3751
+ const send = renderThreadedFrame({
3752
+ type: "tool_activity",
3753
+ sessionId: owner.sessionId,
3754
+ toolCallId: owner.toolCallId,
3755
+ toolName: owner.toolName,
3756
+ phase: "unknown",
3757
+ });
3758
+ if (!send?.text) continue;
3759
+ let failure: unknown;
3760
+ let delivered = false;
3761
+ for (let attempt = 0; attempt < (strict ? 5 : 1); attempt++) {
3762
+ try {
3763
+ const response = (await this.botApi.call("editMessageText", {
3764
+ chat_id: this.opts.chatId,
3765
+ message_id: messageId,
3766
+ text: send.text,
3767
+ parse_mode: TELEGRAM_PARSE_MODE,
3768
+ })) as { ok?: boolean; description?: string } | undefined;
3769
+ delivered = response?.ok === true || /not modified/i.test(String(response?.description ?? ""));
3770
+ if (delivered) break;
3771
+ failure = new Error(String(response?.description ?? "Telegram rejected tool terminalization."));
3772
+ } catch (error) {
3773
+ failure = error;
3774
+ }
3775
+ if (strict && attempt < 4) await this.runtime.sleep(50 * 2 ** attempt);
3776
+ }
3777
+ if (delivered) {
3778
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3779
+ continue;
3780
+ }
3781
+ this.unresolvedToolTerminalizations.set(backlogKey, item);
3782
+ if (strict) {
3783
+ const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
3784
+ if (!this.toolActivityOwners.has(key)) {
3785
+ this.toolActivityOwners.set(key, owner);
3786
+ this.liveMessages.set(key, messageId);
3787
+ }
3788
+ strictFailures.push(failure instanceof Error ? failure : new Error("Tool terminalization failed."));
3789
+ }
3790
+ }
3791
+ if (strictFailures.length > 0) {
3792
+ throw new AggregateError(strictFailures, strictFailures.map(failure => failure.message).join("; "));
3793
+ }
3794
+ });
3795
+ const next = this.toolTerminalizationChain.then(terminalize);
3796
+ this.toolTerminalizationChain = next.catch(() => {});
3797
+ return next;
3798
+ }
3799
+
3800
+ private scheduleVisibleToolTerminalization(endpointDigest?: string, strict = false): Promise<void> {
3801
+ if (endpointDigest !== undefined) this.revokedToolEndpoints.add(endpointDigest);
3802
+ const claimVisible = (): Array<{ messageId: number; owner: ToolActivityOwner }> => {
3803
+ const claimedByKey = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
3804
+ for (const [backlogKey, item] of this.unresolvedToolTerminalizations) {
3805
+ if (endpointDigest !== undefined && item.owner.endpointDigest !== endpointDigest) continue;
3806
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3807
+ claimedByKey.set(backlogKey, item);
3808
+ }
3809
+ for (const [key, owner] of this.toolActivityOwners) {
3810
+ if (endpointDigest !== undefined && owner.endpointDigest !== endpointDigest) continue;
3811
+ this.toolActivityOwners.delete(key);
3812
+ const messageId = this.liveMessages.get(key);
3813
+ this.liveMessages.delete(key);
3814
+ if (messageId !== undefined) {
3815
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3816
+ claimedByKey.set(backlogKey, { messageId, owner });
3817
+ }
3818
+ }
3819
+ return [...claimedByKey.values()];
3820
+ };
3821
+ this.pool.removeWhere(
3822
+ item =>
3823
+ item.payload.toolActivity !== undefined &&
3824
+ (endpointDigest === undefined || item.payload.toolActivity.endpointDigest === endpointDigest),
3825
+ );
3826
+ const next = this.enqueueToolTerminalization(claimVisible, false, strict);
3827
+ if (endpointDigest !== undefined) {
3828
+ void next.then(
3829
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3830
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3831
+ );
3832
+ }
3833
+ return next;
3834
+ }
3835
+
3836
+ private beginToolActivityShutdown(): Promise<void> {
3837
+ if (this.toolActivityStopping) return this.toolShutdownBarrier;
3838
+ this.toolActivityStopping = true;
3839
+ this.toolActivityPolicyEpoch++;
3840
+ this.toolShutdownBarrier = (async () => {
3841
+ await this.flushChain;
3842
+ const failures: Error[] = [];
3843
+ if (this.toolActivityAmbiguous) {
3844
+ failures.push(new Error("Tool activity delivery became ambiguous during daemon shutdown."));
3845
+ }
3846
+ try {
3847
+ await this.scheduleVisibleToolTerminalization(undefined, true);
3848
+ } catch (error) {
3849
+ failures.push(error instanceof Error ? error : new Error(String(error)));
3850
+ }
3851
+ if (failures.length > 0) {
3852
+ throw new AggregateError(failures, failures.map(failure => failure.message).join("; "));
3853
+ }
3854
+ })();
3855
+ return this.toolShutdownBarrier;
3856
+ }
3857
+
2461
3858
  private dropSession(session: SessionSocket, reason: string): void {
3859
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
3860
+ if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
2462
3861
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
2463
3862
  if (session.pingTimer) {
2464
3863
  clearIntervalImpl(session.pingTimer);
@@ -2469,7 +3868,6 @@ export class TelegramNotificationDaemon {
2469
3868
  } else {
2470
3869
  void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2471
3870
  }
2472
- const isCurrentSession = this.sessions.get(session.sessionId) === session;
2473
3871
  if (isCurrentSession || reason === "session_closed") {
2474
3872
  this.deleteMessageRoutes(session.sessionId);
2475
3873
  }
@@ -2929,16 +4327,39 @@ export class TelegramNotificationDaemon {
2929
4327
  return !ownerId || ownerId === session.sessionId;
2930
4328
  }
2931
4329
 
4330
+ private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4331
+ if (msg.type !== "tool_activity") return undefined;
4332
+ const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
4333
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
4334
+ const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4335
+ if (!toolCallId || !toolName || !phase) return undefined;
4336
+ return {
4337
+ sessionId: session.sessionId,
4338
+ toolCallId,
4339
+ toolName,
4340
+ endpointDigest: session.endpointDigest,
4341
+ session,
4342
+ phase: phase === "started" ? "started" : "terminal",
4343
+ };
4344
+ }
4345
+
4346
+ private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4347
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4348
+ const session = this.sessions.get(toolActivity.sessionId);
4349
+ if (toolActivity.endpointDigest === undefined) return session === undefined;
4350
+ return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4351
+ }
2932
4352
  private async submitThreadedFrame(
2933
4353
  sessionId: string,
2934
4354
  send: ThreadedSend,
2935
4355
  topicLease: TopicAuthorityLease,
4356
+ toolActivity?: ToolActivityOwner,
2936
4357
  ): Promise<void> {
2937
4358
  this.submitPool({
2938
4359
  sessionId,
2939
4360
  lane: send.lane,
2940
4361
  coalesceKey: send.coalesceKey,
2941
- payload: { send, topicLease },
4362
+ payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
2942
4363
  });
2943
4364
  await this.flushPool();
2944
4365
  }
@@ -3000,18 +4421,33 @@ export class TelegramNotificationDaemon {
3000
4421
  }
3001
4422
  }
3002
4423
 
3003
- private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
3004
- const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
3005
- frames.push({ send, msg });
4424
+ private rememberPendingThreadedFrame(
4425
+ session: SessionSocket,
4426
+ send: ThreadedSend,
4427
+ msg: Record<string, unknown>,
4428
+ toolActivity?: ToolActivityOwner,
4429
+ ): void {
4430
+ const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
4431
+ frames.push({
4432
+ send,
4433
+ msg,
4434
+ endpointDigest: session.endpointDigest,
4435
+ ...(toolActivity ? { toolActivity } : {}),
4436
+ });
3006
4437
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
3007
- this.pendingThreadedFrames.set(sessionId, frames);
4438
+ this.pendingThreadedFrames.set(session.sessionId, frames);
3008
4439
  }
3009
4440
 
3010
4441
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
3011
4442
  const frames = this.pendingThreadedFrames.get(sessionId);
3012
4443
  if (!frames || frames.length === 0) return;
3013
4444
  this.pendingThreadedFrames.delete(sessionId);
3014
- for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
4445
+ const session = this.sessions.get(sessionId);
4446
+ for (const frame of frames) {
4447
+ if (session && session.endpointDigest !== frame.endpointDigest) continue;
4448
+ if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4449
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
4450
+ }
3015
4451
  }
3016
4452
 
3017
4453
  /**
@@ -3096,7 +4532,11 @@ export class TelegramNotificationDaemon {
3096
4532
  })) as { ok?: boolean };
3097
4533
  if (!topicDeleteSettled(res)) return;
3098
4534
  this.topics.settleDelete(sessionId, record.topicId);
3099
- for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
4535
+ for (const k of [...this.liveMessages.keys()])
4536
+ if (k.startsWith(`${sessionId}:`)) {
4537
+ this.liveMessages.delete(k);
4538
+ this.toolActivityOwners.delete(k);
4539
+ }
3100
4540
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
3101
4541
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
3102
4542
  });
@@ -3280,6 +4720,21 @@ export class TelegramNotificationDaemon {
3280
4720
  );
3281
4721
  }
3282
4722
  for (const item of batch) {
4723
+ const toolActivity = item.payload.toolActivity;
4724
+ if (
4725
+ toolActivity?.phase === "started" &&
4726
+ (this.toolActivityStopping ||
4727
+ this.opts.toolActivity?.enabled === false ||
4728
+ toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
4729
+ !this.toolActivityAuthorityIsCurrent(toolActivity))
4730
+ ) {
4731
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
4732
+ const owner = this.toolActivityOwners.get(key);
4733
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
4734
+ this.toolActivityOwners.delete(key);
4735
+ this.pool.settle(item.itemId!, "removed");
4736
+ continue;
4737
+ }
3283
4738
  const btwDelivery = item.payload.btwDelivery;
3284
4739
  if (btwDelivery) {
3285
4740
  if (!btwDelivery.isAuthoritative()) {
@@ -3374,6 +4829,10 @@ export class TelegramNotificationDaemon {
3374
4829
  this.pool.settle(item.itemId!, "rejected");
3375
4830
  continue;
3376
4831
  }
4832
+ if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
4833
+ this.pool.settle(item.itemId!, "removed");
4834
+ continue;
4835
+ }
3377
4836
  // Threaded topic when available; otherwise deliver flat to the paired chat.
3378
4837
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
3379
4838
  const ckey = send.editable ? item.coalesceKey : undefined;
@@ -3572,19 +5031,25 @@ export class TelegramNotificationDaemon {
3572
5031
  }
3573
5032
  }
3574
5033
  if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
3575
- this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
5034
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
3576
5035
  }
3577
5036
  }
3578
5037
  }
3579
5038
  } catch {
3580
5039
  // Best-effort: a failed send/edit must never stop the daemon.
3581
5040
  disposition = "ambiguous";
5041
+ if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
3582
5042
  } finally {
3583
5043
  this.pool.settle(item.itemId!, disposition);
3584
5044
  // A terminal tool frame owns the end of this coalescing key even when both
3585
5045
  // edit and fallback delivery fail. Retaining the old message id would leak
3586
5046
  // one entry per failure and let a later reused key edit stale Telegram state.
3587
- if (send.terminal && editKey) this.liveMessages.delete(editKey);
5047
+ if (send.terminal && editKey) {
5048
+ this.liveMessages.delete(editKey);
5049
+ const owner = this.toolActivityOwners.get(editKey);
5050
+ if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
5051
+ this.toolActivityOwners.delete(editKey);
5052
+ }
3588
5053
  }
3589
5054
  }
3590
5055
  }
@@ -3594,7 +5059,12 @@ export class TelegramNotificationDaemon {
3594
5059
  * so later live/finalized frames edit it in place. Evicts this session's stale
3595
5060
  * same-category entries (e.g. prior turns) so the map stays bounded.
3596
5061
  */
3597
- private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
5062
+ private recordLiveMessage(
5063
+ sessionId: string,
5064
+ coalesceKey: string,
5065
+ messageId: number,
5066
+ toolActivity?: ToolActivityOwner,
5067
+ ): void {
3598
5068
  const mapKey = `${sessionId}:${coalesceKey}`;
3599
5069
  const category = coalesceKey.split(":")[0] ?? "";
3600
5070
  // Single-slot categories (rolling turn/context/reasoning previews) evict prior
@@ -3609,7 +5079,15 @@ export class TelegramNotificationDaemon {
3609
5079
  if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
3610
5080
  }
3611
5081
  }
5082
+ if (toolActivity) {
5083
+ const owner = this.toolActivityOwners.get(mapKey);
5084
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
5085
+ void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
5086
+ return;
5087
+ }
5088
+ }
3612
5089
  this.liveMessages.set(mapKey, messageId);
5090
+ if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
3613
5091
  }
3614
5092
 
3615
5093
  /**
@@ -3621,11 +5099,20 @@ export class TelegramNotificationDaemon {
3621
5099
  * content never lands in a shared chat. Identity headers are sent at most once per
3622
5100
  * session in flat mode.
3623
5101
  */
3624
- private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
5102
+ private async deliverFlatFallback(
5103
+ sessionId: string,
5104
+ send: ThreadedSend,
5105
+ toolActivity?: ToolActivityOwner,
5106
+ ): Promise<void> {
3625
5107
  if (!(await this.pairedChatIsPrivate())) return;
3626
5108
  await this.notifyThreadedFallback();
3627
5109
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
3628
- this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
5110
+ this.submitPool({
5111
+ sessionId,
5112
+ lane: send.lane,
5113
+ coalesceKey: send.coalesceKey,
5114
+ payload: { send, ...(toolActivity ? { toolActivity } : {}) },
5115
+ });
3629
5116
  await this.flushPool();
3630
5117
  if (send.identity) this.flatIdentitySent.add(sessionId);
3631
5118
  }
@@ -3697,11 +5184,13 @@ export class TelegramNotificationDaemon {
3697
5184
  return renewDaemonHeartbeat({
3698
5185
  settings: this.opts.settings,
3699
5186
  ownerId: this.opts.ownerId,
5187
+ acquisitionId: this.opts.ownerId,
3700
5188
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
3701
5189
  chatId: this.opts.chatId,
3702
5190
  fs: this.fsImpl,
3703
5191
  now: this.opts.now,
3704
5192
  pid: this.opts.pid ?? process.pid,
5193
+ pidIncarnation: this.opts.pidIncarnation,
3705
5194
  });
3706
5195
  }
3707
5196
 
@@ -3848,9 +5337,13 @@ export class TelegramNotificationDaemon {
3848
5337
 
3849
5338
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
3850
5339
  if (session.replayPending) {
5340
+ if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5341
+ return;
3851
5342
  const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
3852
5343
  if (!matchingReplay) {
3853
- session.replayQueue.push(msg as Record<string, unknown>);
5344
+ const frame = msg as Record<string, unknown>;
5345
+ if (frame.type === "tool_activity") this.replayToolActivityEpochs.set(frame, this.toolActivityPolicyEpoch);
5346
+ session.replayQueue.push(frame);
3854
5347
  return;
3855
5348
  }
3856
5349
  session.replayPending = false;
@@ -3895,6 +5388,11 @@ export class TelegramNotificationDaemon {
3895
5388
  }
3896
5389
  const queued = session.replayQueue.splice(0);
3897
5390
  for (const frame of queued) {
5391
+ if (
5392
+ frame.type === "tool_activity" &&
5393
+ this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
5394
+ )
5395
+ continue;
3898
5396
  const fingerprint = JSON.stringify(frame);
3899
5397
  const remaining = replayCounts.get(fingerprint) ?? 0;
3900
5398
  if (remaining > 0) {
@@ -4129,12 +5627,63 @@ export class TelegramNotificationDaemon {
4129
5627
  return;
4130
5628
  }
4131
5629
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
5630
+ const threadedFrame = msg as Record<string, unknown>;
5631
+ const toolActivity = this.toolActivityOwner(session, threadedFrame);
5632
+ const toolAdmissionEpoch = toolActivity
5633
+ ? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
5634
+ : undefined;
5635
+ if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
5636
+ const toolStartIsCurrent = (): boolean =>
5637
+ toolActivity?.phase !== "started" ||
5638
+ (this.toolActivityAuthorityIsCurrent(toolActivity) &&
5639
+ !this.toolActivityStopping &&
5640
+ this.opts.toolActivity?.enabled !== false &&
5641
+ toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5642
+ const abandonStaleToolStart = (): void => {
5643
+ if (toolActivity?.phase !== "started") return;
5644
+ const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5645
+ const owner = this.toolActivityOwners.get(key);
5646
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5647
+ this.toolActivityOwners.delete(key);
5648
+ };
5649
+ if (toolActivity) {
5650
+ const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5651
+ const currentOwner = this.toolActivityOwners.get(liveKey);
5652
+ if (toolActivity.phase === "started") {
5653
+ if (!toolStartIsCurrent()) return;
5654
+ this.toolActivityOwners.set(liveKey, toolActivity);
5655
+ } else {
5656
+ if (currentOwner && currentOwner.session !== session) return;
5657
+ if (this.opts.toolActivity?.enabled === false) {
5658
+ if (!currentOwner) return;
5659
+ if (!this.liveMessages.has(liveKey)) {
5660
+ // A start may already be granted to the serialized dispatcher but not
5661
+ // yet recorded as visible. Wait for that exact dispatch boundary before
5662
+ // deciding whether its terminal frame must close a visible message.
5663
+ await this.flushChain;
5664
+ }
5665
+ const settledOwner = this.toolActivityOwners.get(liveKey);
5666
+ if (!this.liveMessages.has(liveKey) || settledOwner?.session !== session) {
5667
+ if (settledOwner?.session === session) this.toolActivityOwners.delete(liveKey);
5668
+ return;
5669
+ }
5670
+ }
5671
+ }
5672
+ }
4132
5673
  const send = renderThreadedFrame(msg);
4133
5674
  if (!send) return;
4134
5675
  const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
5676
+ if (!toolStartIsCurrent()) {
5677
+ abandonStaleToolStart();
5678
+ return;
5679
+ }
4135
5680
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4136
5681
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
4137
- this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
5682
+ if (!toolStartIsCurrent()) {
5683
+ abandonStaleToolStart();
5684
+ return;
5685
+ }
5686
+ this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
4138
5687
  return;
4139
5688
  }
4140
5689
  if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
@@ -4148,9 +5697,13 @@ export class TelegramNotificationDaemon {
4148
5697
  const topicId =
4149
5698
  existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
4150
5699
  const topicLease = await this.topicAuthorityLease(session.sessionId);
5700
+ if (!toolStartIsCurrent()) {
5701
+ abandonStaleToolStart();
5702
+ return;
5703
+ }
4151
5704
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
4152
5705
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4153
- await this.deliverFlatFallback(session.sessionId, send);
5706
+ await this.deliverFlatFallback(session.sessionId, send, toolActivity);
4154
5707
  return;
4155
5708
  }
4156
5709
  if (send.identity) {
@@ -4198,7 +5751,11 @@ export class TelegramNotificationDaemon {
4198
5751
  await this.persistTopics();
4199
5752
  return;
4200
5753
  }
4201
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5754
+ if (!toolStartIsCurrent()) {
5755
+ abandonStaleToolStart();
5756
+ return;
5757
+ }
5758
+ await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
4202
5759
  return;
4203
5760
  }
4204
5761
  if (msg.type === "action_needed" && msg.id) {
@@ -4513,28 +6070,32 @@ export class TelegramNotificationDaemon {
4513
6070
  }
4514
6071
  }
4515
6072
  }
4516
- // Rich-message toggle (/rich on|off): daemon-local delivery policy, NOT a
4517
- // session config forward. Handled at paired-chat pre-routing, before threaded
4518
- // injection and independent of any session WebSocket, so it works even when
4519
- // no session is connected and never becomes an ask answer.
6073
+ // Telegram delivery toggles are daemon-local policy, NOT session config
6074
+ // forwards. Handle them before threaded injection and independently of any
6075
+ // session WebSocket, so they work even when no session is connected.
4520
6076
  {
4521
6077
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
4522
6078
  const chat = m?.chat as { id?: unknown } | undefined;
4523
6079
  const cmdText = typeof m?.text === "string" ? m.text : undefined;
4524
- const rawFirst = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
4525
- // Fail-closed: intercept ANY "/rich" or "/rich@<anything>" form (Telegram
4526
- // appends @botname in groups; the bot username may be unknown if getMe
4527
- // failed) so a rich command is never leaked into threaded injection / an
4528
- // ask answer. Argument validity is decided by parseRichToggleCommand below.
4529
- const isRichCommand = rawFirst?.split("@")[0] === "/rich";
4530
- if (m !== undefined && String(chat?.id) === String(this.opts.chatId) && isRichCommand) {
4531
- // Fail-closed: /rich mutates global config, so honor it ONLY in a PRIVATE
4532
- // paired chat — the same contract as session delivery and lifecycle
4533
- // commands. A group/supergroup chatId (legacy or hand-edited) must never
4534
- // let an arbitrary chat member toggle the owner's notification config.
6080
+ const commandToken = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
6081
+ const [command, commandSuffix] = commandToken?.split("@", 2) ?? [];
6082
+ const isRichCommand = command === "/rich";
6083
+ const isToolActivityCommand = command === "/toolactivity";
6084
+ if (
6085
+ isToolActivityCommand &&
6086
+ commandSuffix &&
6087
+ (!this.botUsername || commandSuffix !== this.botUsername.toLowerCase())
6088
+ )
6089
+ return;
6090
+ if (
6091
+ m !== undefined &&
6092
+ String(chat?.id) === String(this.opts.chatId) &&
6093
+ (isRichCommand || isToolActivityCommand)
6094
+ ) {
6095
+ // These commands mutate global config, so honor them ONLY in the
6096
+ // configured private chat. Fail closed for legacy or hand-edited group IDs.
4535
6097
  if (!(await this.pairedChatIsPrivate())) return;
4536
6098
  const updateId = (update as { update_id?: number }).update_id;
4537
- // Dedupe redelivered updates so a toggle+confirmation runs at most once.
4538
6099
  if (typeof updateId === "number") {
4539
6100
  if (this.dispatchState.seenUpdateIds.has(updateId)) return;
4540
6101
  await this.rememberSeenUpdateId(updateId);
@@ -4553,31 +6114,70 @@ export class TelegramNotificationDaemon {
4553
6114
  // Best-effort confirmation; never block on the notice.
4554
6115
  }
4555
6116
  };
4556
- const desired = parseRichToggleCommand(cmdText ?? "");
6117
+ const desired = isRichCommand
6118
+ ? parseRichToggleCommand(cmdText ?? "")
6119
+ : parseToolActivityToggleCommand(cmdText ?? "", this.botUsername);
6120
+ const usage = isRichCommand ? "Usage: /rich on|off" : "Usage: /toolactivity on|off";
4557
6121
  if (desired === undefined) {
4558
- await reply("Usage: /rich on|off");
6122
+ await reply(usage);
4559
6123
  return;
4560
6124
  }
6125
+ const settingPath = isRichCommand
6126
+ ? "notifications.telegram.rich.enabled"
6127
+ : "notifications.telegram.toolActivity.enabled";
6128
+ const label = isRichCommand ? "Rich messages" : "Tool activity";
4561
6129
  try {
4562
- await this.opts.settings.set("notifications.telegram.rich.enabled", desired);
4563
- // Confirm success only after a DURABLE write. The real Settings.set is
4564
- // a synchronous fire-and-forget whose queued save (Settings.#saveNow)
4565
- // swallows write errors, and Settings.flush() inherits that — neither
4566
- // rejects on a failed config.yml write. flushOrThrow() rethrows the
4567
- // durable-write failure so it lands in the catch below (in-memory
4568
- // isolated Settings short-circuit and never throw). The lightweight
4569
- // daemon settings has no flushOrThrow: its set() already wrote durably
4570
- // (and throws on failure), so its flush() is only a no-op drain.
4571
- await flushRichToggleSettings(this.opts.settings);
6130
+ await this.opts.settings.set(settingPath, desired);
6131
+ // Confirm only after the global config write is durable.
6132
+ await flushTelegramToggleSettings(this.opts.settings);
4572
6133
  } catch (err) {
4573
6134
  logger.warn(
4574
- `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
6135
+ `notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
4575
6136
  );
4576
- await reply("Rich messages: unchanged (settings write failed)");
6137
+ await reply(`${label}: unchanged (settings write failed)`);
4577
6138
  return;
4578
6139
  }
4579
- this.opts.rich = { enabled: desired };
4580
- await reply(desired ? "Rich messages: on" : "Rich messages: off");
6140
+ if (isRichCommand) {
6141
+ this.opts.rich = { enabled: desired };
6142
+ } else {
6143
+ this.toolActivityPolicyEpoch++;
6144
+ this.opts.toolActivity = { enabled: desired };
6145
+ if (!desired) {
6146
+ const removedTools = this.pool.removeWhere(
6147
+ item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
6148
+ );
6149
+ for (const item of removedTools) {
6150
+ const toolActivity = item.payload.toolActivity;
6151
+ if (!toolActivity) continue;
6152
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6153
+ const owner = this.toolActivityOwners.get(key);
6154
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6155
+ this.toolActivityOwners.delete(key);
6156
+ }
6157
+ for (const [sessionId, frames] of this.pendingThreadedFrames) {
6158
+ for (const frame of frames) {
6159
+ const toolActivity = frame.toolActivity;
6160
+ if (!toolActivity || frame.msg.type !== "tool_activity") continue;
6161
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6162
+ const owner = this.toolActivityOwners.get(key);
6163
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6164
+ this.toolActivityOwners.delete(key);
6165
+ }
6166
+ const retained = frames.filter(frame => frame.msg.type !== "tool_activity");
6167
+ if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
6168
+ else this.pendingThreadedFrames.set(sessionId, retained);
6169
+ }
6170
+ for (const session of this.sessions.values()) {
6171
+ session.replayQueue = session.replayQueue.filter(frame => frame.type !== "tool_activity");
6172
+ }
6173
+ }
6174
+ // The policy flips before joining the serialized dispatch chain:
6175
+ // future starts are rejected immediately, queued starts are removed,
6176
+ // and any already-granted Bot API effect settles before the off
6177
+ // acknowledgement. Visible starts may still receive their terminal edit.
6178
+ await this.flushChain;
6179
+ }
6180
+ await reply(`${label}: ${desired ? "on" : "off"}`);
4581
6181
  return;
4582
6182
  }
4583
6183
  }
@@ -4895,6 +6495,10 @@ export class TelegramNotificationDaemon {
4895
6495
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
4896
6496
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
4897
6497
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
6498
+ {
6499
+ command: "toolactivity",
6500
+ description: "Toggle tool activity updates: /toolactivity <on|off>",
6501
+ },
4898
6502
  { command: "reasoning", description: "Show or change reasoning effort in this session" },
4899
6503
  { command: "usage", description: "Show provider/local usage for this session" },
4900
6504
  { command: "model", description: "Select a model for this session" },
@@ -4923,11 +6527,13 @@ export class TelegramNotificationDaemon {
4923
6527
  this.running = await renewDaemonHeartbeat({
4924
6528
  settings: this.opts.settings,
4925
6529
  ownerId: this.opts.ownerId,
6530
+ acquisitionId: this.opts.ownerId,
4926
6531
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
4927
6532
  chatId: this.opts.chatId,
4928
6533
  fs: this.fsImpl,
4929
6534
  now: this.opts.now,
4930
6535
  pid: this.opts.pid ?? process.pid,
6536
+ pidIncarnation: this.opts.pidIncarnation,
4931
6537
  });
4932
6538
  if (!this.running) return;
4933
6539
  this.runtime.start();
@@ -4949,7 +6555,20 @@ export class TelegramNotificationDaemon {
4949
6555
  let idleSince = this.runtime.now();
4950
6556
  while (this.running) {
4951
6557
  if (await this.controlStopRequested()) break;
4952
- if (!(await this.renewOwnershipHeartbeat())) break;
6558
+ if (
6559
+ !(await renewDaemonHeartbeat({
6560
+ settings: this.opts.settings,
6561
+ ownerId: this.opts.ownerId,
6562
+ acquisitionId: this.opts.ownerId,
6563
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
6564
+ chatId: this.opts.chatId,
6565
+ fs: this.fsImpl,
6566
+ now: this.opts.now,
6567
+ pid: this.opts.pid ?? process.pid,
6568
+ pidIncarnation: this.opts.pidIncarnation,
6569
+ }))
6570
+ )
6571
+ break;
4953
6572
  await this.runScan();
4954
6573
  if (await this.controlStopRequested()) break;
4955
6574
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -4984,10 +6603,19 @@ export class TelegramNotificationDaemon {
4984
6603
  await this.runtime.sleep(10);
4985
6604
  }
4986
6605
  } finally {
6606
+ let toolShutdownError: unknown;
6607
+ try {
6608
+ await this.beginToolActivityShutdown();
6609
+ } catch (error) {
6610
+ toolShutdownError = error;
6611
+ }
4987
6612
  this.effects.beginShutdown();
6613
+ this.#deliveryAbort.abort();
4988
6614
  let persisted = false;
4989
6615
  const shutdown = this.effects.allowTerminal(async () => {
6616
+ if (toolShutdownError) throw toolShutdownError;
4990
6617
  await this.#drainBtwTurns();
6618
+ await this.toolTerminalizationChain;
4991
6619
  this.runtime.stop();
4992
6620
  this.stopOwnershipHeartbeatTimer();
4993
6621
  this.stopFlushTimer();
@@ -5021,9 +6649,12 @@ export class TelegramNotificationDaemon {
5021
6649
  await releaseDaemonOwnership({
5022
6650
  settings: this.opts.settings,
5023
6651
  ownerId: this.opts.ownerId,
6652
+ acquisitionId: this.opts.ownerId,
5024
6653
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
5025
6654
  chatId: this.opts.chatId,
5026
6655
  pid: this.opts.pid ?? process.pid,
6656
+ generation: DAEMON_GENERATION,
6657
+ pidIncarnation: this.opts.pidIncarnation,
5027
6658
  fs: this.fsImpl,
5028
6659
  now: this.opts.now,
5029
6660
  });