@gajae-code/coding-agent 0.11.4 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) 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 +20 -0
  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 +39 -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 +6 -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 +88 -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/task/executor.d.ts +9 -0
  50. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  51. package/dist/types/tools/ask.d.ts +48 -0
  52. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  53. package/dist/types/tools/index.d.ts +4 -0
  54. package/package.json +9 -9
  55. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  56. package/src/cli/notify-cli.ts +40 -18
  57. package/src/commands/sdk.ts +7 -2
  58. package/src/config/settings-schema.ts +17 -0
  59. package/src/daemon/runtime.ts +2 -2
  60. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  61. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  62. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  64. package/src/eval/py/index.ts +2 -2
  65. package/src/export/html/template.generated.ts +1 -1
  66. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  67. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  68. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  69. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  70. package/src/gjc-runtime/launch-tmux.ts +61 -6
  71. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  72. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  73. package/src/gjc-runtime/state-runtime.ts +38 -12
  74. package/src/gjc-runtime/team-runtime.ts +1 -1
  75. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  76. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  77. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  78. package/src/harness-control-plane/owner.ts +59 -3
  79. package/src/hindsight/client.ts +65 -2
  80. package/src/internal-urls/agent-protocol.ts +76 -5
  81. package/src/internal-urls/docs-index.generated.ts +7 -6
  82. package/src/internal-urls/local-protocol.ts +416 -18
  83. package/src/lsp/index.ts +4 -88
  84. package/src/main.ts +37 -13
  85. package/src/modes/acp/acp-agent.ts +8 -0
  86. package/src/modes/components/ansi-display-validator.ts +54 -0
  87. package/src/modes/components/notifications-settings-editor.ts +26 -2
  88. package/src/modes/components/session-observer-overlay.ts +40 -24
  89. package/src/modes/components/settings-selector.ts +3 -1
  90. package/src/modes/components/tool-transcript-format.ts +321 -0
  91. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  92. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  93. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  94. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  95. package/src/modes/controllers/selector-controller.ts +70 -8
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  98. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  99. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  100. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  101. package/src/plan-mode/approved-plan.ts +3 -1
  102. package/src/prompts/agents/architect.md +1 -0
  103. package/src/prompts/agents/critic.md +1 -0
  104. package/src/prompts/system/custom-system-prompt.md +1 -1
  105. package/src/prompts/system/system-prompt.md +2 -1
  106. package/src/prompts/tools/lsp.md +3 -3
  107. package/src/runtime-mcp/manager.ts +16 -1
  108. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  109. package/src/sdk/broker/session-index.ts +122 -64
  110. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  111. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  112. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  113. package/src/sdk/bus/config-commands.ts +22 -0
  114. package/src/sdk/bus/config.ts +37 -0
  115. package/src/sdk/bus/index.ts +384 -92
  116. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  117. package/src/sdk/bus/notification-service.ts +284 -62
  118. package/src/sdk/bus/recent-activity.ts +14 -5
  119. package/src/sdk/bus/session-control.ts +150 -44
  120. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  121. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  122. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  123. package/src/sdk/bus/telegram-daemon.ts +1788 -267
  124. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  125. package/src/sdk/session.ts +155 -27
  126. package/src/secrets/index.ts +53 -10
  127. package/src/secrets/obfuscator.ts +21 -18
  128. package/src/secrets/regex.ts +2 -0
  129. package/src/session/agent-session.ts +495 -253
  130. package/src/session/artifacts.ts +188 -23
  131. package/src/session/context-estimation.ts +7 -2
  132. package/src/session/internal/managed-session-scope.ts +200 -38
  133. package/src/session/internal/managed-session-storage.ts +1152 -46
  134. package/src/session/session-manager.ts +1055 -148
  135. package/src/session/session-storage.ts +109 -11
  136. package/src/session/streaming-output.ts +6 -0
  137. package/src/task/executor.ts +71 -26
  138. package/src/task/index.ts +63 -20
  139. package/src/tool-discovery/tool-index.ts +18 -0
  140. package/src/tools/ask.ts +129 -32
  141. package/src/tools/bash-skill-urls.ts +14 -3
  142. package/src/tools/bash.ts +2 -2
  143. package/src/tools/index.ts +4 -0
  144. package/src/tools/plan-mode-guard.ts +1 -0
  145. package/src/tools/sqlite-reader.ts +3 -2
  146. 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,212 @@ function validBotToken(token: unknown): token is string {
524
857
  return typeof token === "string" && token.length > 0;
525
858
  }
526
859
 
527
- function ownerIdentityMatches(state: DaemonState, tokenFingerprint: string, chatId: string): boolean {
860
+ function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
861
+ return Boolean(
862
+ hasSafeDaemonStateShape(state) &&
863
+ state.stoppedAt !== undefined &&
864
+ typeof state.acquisitionId === "string" &&
865
+ state.acquisitionId.length > 0,
866
+ );
867
+ }
868
+
869
+ function ownerIdentityMatches(
870
+ state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
871
+ tokenFingerprint: string,
872
+ chatId: string,
873
+ ): boolean {
528
874
  return state.tokenFingerprint === tokenFingerprint && state.chatId === chatId;
529
875
  }
530
876
 
531
- function liveOwnerUsesDifferentIdentity(input: {
877
+ function ownerProvenanceMatches(state: DaemonState, pidIncarnation?: (pid: number) => string | undefined): boolean {
878
+ const current = (pidIncarnation ?? defaultPidIncarnation)(state.pid);
879
+ return isProcessIncarnation(state.incarnation) && isProcessIncarnation(current) && current === state.incarnation;
880
+ }
881
+
882
+ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
883
+ if (!state || typeof state !== "object" || Array.isArray(state)) return false;
884
+ const candidate = state as Partial<DaemonState>;
885
+ return Boolean(
886
+ Number.isSafeInteger(candidate.pid) &&
887
+ (candidate.pid as number) > 0 &&
888
+ typeof candidate.ownerId === "string" &&
889
+ candidate.ownerId.length > 0 &&
890
+ (candidate.acquisitionId === undefined ||
891
+ (typeof candidate.acquisitionId === "string" && candidate.acquisitionId.length > 0)) &&
892
+ (candidate.ownershipPhase === undefined ||
893
+ candidate.ownershipPhase === "provisional" ||
894
+ candidate.ownershipPhase === "ready" ||
895
+ candidate.ownershipPhase === "retired") &&
896
+ typeof candidate.tokenFingerprint === "string" &&
897
+ typeof candidate.chatId === "string" &&
898
+ Number.isSafeInteger(candidate.startedAt) &&
899
+ Number.isSafeInteger(candidate.heartbeatAt) &&
900
+ isProcessIncarnation(candidate.incarnation) &&
901
+ (candidate.launcherPid === undefined ||
902
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid as number) > 0)) &&
903
+ Array.isArray(candidate.roots) &&
904
+ candidate.roots.every(root => typeof root === "string") &&
905
+ candidate.version === DAEMON_VERSION &&
906
+ (candidate.generation === undefined ||
907
+ (Number.isSafeInteger(candidate.generation) && (candidate.generation as number) > 0)) &&
908
+ (candidate.stoppedAt === undefined || Number.isSafeInteger(candidate.stoppedAt)),
909
+ );
910
+ }
911
+
912
+ type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
913
+ incarnation?: undefined;
914
+ acquisitionId?: undefined;
915
+ ownershipPhase?: undefined;
916
+ generation?: undefined;
917
+ };
918
+
919
+ interface LegacyMigrationAttestation {
920
+ pid: number;
921
+ incarnation: string;
922
+ heartbeatAt: number;
923
+ observedAt: number;
924
+ }
925
+
926
+ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
927
+ const candidate = state as Partial<LegacyParentDaemonState> | undefined;
928
+ return Boolean(
929
+ candidate &&
930
+ Number.isSafeInteger(candidate.pid) &&
931
+ (candidate.pid ?? 0) > 0 &&
932
+ typeof candidate.ownerId === "string" &&
933
+ candidate.ownerId.length > 0 &&
934
+ typeof candidate.tokenFingerprint === "string" &&
935
+ typeof candidate.chatId === "string" &&
936
+ Number.isSafeInteger(candidate.startedAt) &&
937
+ Number.isSafeInteger(candidate.heartbeatAt) &&
938
+ Array.isArray(candidate.roots) &&
939
+ candidate.roots.every(root => typeof root === "string") &&
940
+ candidate.version === DAEMON_VERSION &&
941
+ candidate.incarnation === undefined &&
942
+ candidate.acquisitionId === undefined &&
943
+ candidate.ownershipPhase === undefined &&
944
+ candidate.generation === undefined &&
945
+ candidate.stoppedAt === undefined,
946
+ );
947
+ }
948
+
949
+ function legacyMigrationAttestationPath(statePath: string): string {
950
+ return `${statePath}.legacy-migration.json`;
951
+ }
952
+
953
+ async function legacyParentHandoffDecision(input: {
954
+ fs: TelegramDaemonFs;
955
+ statePath: string;
956
+ state: LegacyParentDaemonState;
957
+ now: number;
958
+ pidAlive: (pid: number) => boolean;
959
+ pidIncarnation: (pid: number) => string | undefined;
960
+ tokenFingerprint: string;
961
+ chatId: string;
962
+ }): Promise<
963
+ { acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
964
+ > {
965
+ const { state } = input;
966
+ if (!input.pidAlive(state.pid)) return undefined;
967
+ if (!ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
968
+ return { acquired: false, attached: false, blocked: true };
969
+ const incarnation = input.pidIncarnation(state.pid);
970
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
971
+ const attestationPath = legacyMigrationAttestationPath(input.statePath);
972
+ const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
973
+ const attested = Boolean(
974
+ previous &&
975
+ Number.isSafeInteger(previous.pid) &&
976
+ isProcessIncarnation(previous.incarnation) &&
977
+ Number.isSafeInteger(previous.heartbeatAt) &&
978
+ Number.isSafeInteger(previous.observedAt) &&
979
+ previous.pid === state.pid &&
980
+ previous.incarnation === incarnation &&
981
+ state.heartbeatAt > previous.heartbeatAt &&
982
+ input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
983
+ );
984
+ await writeJsonAtomic(input.fs, attestationPath, {
985
+ pid: state.pid,
986
+ incarnation,
987
+ heartbeatAt: state.heartbeatAt,
988
+ observedAt: input.now,
989
+ } satisfies LegacyMigrationAttestation);
990
+ return attested
991
+ ? { acquired: false, attached: false, reloadRequired: true }
992
+ : { acquired: false, attached: false, provisional: true };
993
+ }
994
+
995
+ function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
996
+ return (
997
+ generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
998
+ );
999
+ }
1000
+
1001
+ /**
1002
+ * Classifies a physically live daemon record before any replacement action.
1003
+ *
1004
+ * A canonical, differing incarnation is the only authoritative proof that a
1005
+ * live PID is no longer the recorded owner. This applies equally to matching
1006
+ * and foreign Telegram identities: identity never substitutes for provenance.
1007
+ */
1008
+ function classifyForeignLiveOwner(input: {
1009
+ state: unknown;
1010
+ tokenFingerprint: string;
1011
+ chatId: string;
1012
+ pidAlive: (pid: number) => boolean;
1013
+ pidIncarnation?: (pid: number) => string | undefined;
1014
+ }): "identity_mismatch" | "ambiguous" | undefined {
1015
+ const state = input.state as Partial<DaemonState> | undefined;
1016
+ // Validate before probing so malformed PIDs never reach the liveness source.
1017
+ if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1018
+ // A stopped tombstone with a canonical acquisition is not a live owner.
1019
+ if (isExplicitlyStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1020
+ const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1021
+ if (
1022
+ !hasSafeDaemonStateShape(state) ||
1023
+ !isProcessIncarnation(state.incarnation) ||
1024
+ !isProcessIncarnation(currentIncarnation)
1025
+ )
1026
+ return "ambiguous";
1027
+ if (currentIncarnation !== state.incarnation) return undefined;
1028
+ return ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ? undefined : "identity_mismatch";
1029
+ }
1030
+
1031
+ /** True for a physically live owner with this configuration, including legacy generations. */
1032
+ export function isPhysicalMatchingOwner(input: {
532
1033
  state: DaemonState | undefined;
533
1034
  tokenFingerprint: string;
534
1035
  chatId: string;
535
1036
  pidAlive: (pid: number) => boolean;
1037
+ pidIncarnation?: (pid: number) => string | undefined;
536
1038
  }): boolean {
537
1039
  const { state } = input;
538
1040
  return Boolean(
539
1041
  state &&
540
- state.version === DAEMON_VERSION &&
1042
+ hasSafeDaemonStateShape(state) &&
541
1043
  state.stoppedAt === undefined &&
542
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
543
- validDaemonPid(state.pid) &&
544
- input.pidAlive(state.pid),
1044
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1045
+ input.pidAlive(state.pid) &&
1046
+ ownerProvenanceMatches(state, input.pidIncarnation),
1047
+ );
1048
+ }
1049
+
1050
+ /** True only for a signalable legacy owner or this generation's ready bound child. */
1051
+ export function isSignalableMatchingOwner(input: {
1052
+ state: DaemonState | undefined;
1053
+ tokenFingerprint: string;
1054
+ chatId: string;
1055
+ pidAlive: (pid: number) => boolean;
1056
+ pidIncarnation?: (pid: number) => string | undefined;
1057
+ }): boolean {
1058
+ const { state } = input;
1059
+ return Boolean(
1060
+ isPhysicalMatchingOwner(input) &&
1061
+ (isRecognizedLegacyGeneration(state?.generation) ||
1062
+ (state?.generation === DAEMON_GENERATION &&
1063
+ state.ownershipPhase === "ready" &&
1064
+ typeof state.acquisitionId === "string" &&
1065
+ state.acquisitionId.length > 0)),
545
1066
  );
546
1067
  }
547
1068
 
@@ -551,16 +1072,37 @@ export function isFreshLiveOwner(input: {
551
1072
  tokenFingerprint: string;
552
1073
  chatId: string;
553
1074
  pidAlive: (pid: number) => boolean;
1075
+ pidIncarnation?: (pid: number) => string | undefined;
554
1076
  }): boolean {
555
1077
  const { state } = input;
556
1078
  return Boolean(
557
1079
  state &&
558
- state.version === DAEMON_VERSION &&
1080
+ hasSafeDaemonStateShape(state) &&
559
1081
  state.stoppedAt === undefined &&
560
1082
  ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
561
1083
  input.now - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
562
- validDaemonPid(state.pid) &&
563
- input.pidAlive(state.pid),
1084
+ input.pidAlive(state.pid) &&
1085
+ ownerProvenanceMatches(state, input.pidIncarnation),
1086
+ );
1087
+ }
1088
+
1089
+ /** True only when a physically live matching owner can serve this build's daemon lifecycle contract. */
1090
+ export function isCurrentCompatibleOwner(input: {
1091
+ state: DaemonState | undefined;
1092
+ now: number;
1093
+ tokenFingerprint: string;
1094
+ chatId: string;
1095
+ pidAlive: (pid: number) => boolean;
1096
+ pidIncarnation?: (pid: number) => string | undefined;
1097
+ }): boolean {
1098
+ const state = input.state;
1099
+ return Boolean(
1100
+ isFreshLiveOwner(input) &&
1101
+ state?.ownershipPhase === "ready" &&
1102
+ typeof state.acquisitionId === "string" &&
1103
+ state.acquisitionId.length > 0 &&
1104
+ Number.isSafeInteger(state.generation) &&
1105
+ (state.generation as number) >= DAEMON_GENERATION,
564
1106
  );
565
1107
  }
566
1108
 
@@ -573,6 +1115,7 @@ export async function acquireDaemonOwnership(input: {
573
1115
  now?: () => number;
574
1116
  pid?: number;
575
1117
  pidAlive?: (pid: number) => boolean;
1118
+ pidIncarnation?: (pid: number) => string | undefined;
576
1119
  randomId?: () => string;
577
1120
  /** Permit one Windows source launcher PID to daemon PID handoff. */
578
1121
  allowPidRebind?: boolean;
@@ -581,8 +1124,10 @@ export async function acquireDaemonOwnership(input: {
581
1124
  }): Promise<{
582
1125
  acquired: boolean;
583
1126
  ownerId?: string;
1127
+ acquisitionId?: string;
584
1128
  attached?: boolean;
585
1129
  blocked?: boolean;
1130
+ provisional?: boolean;
586
1131
  reason?: "identity_mismatch";
587
1132
  reloadRequired?: boolean;
588
1133
  }> {
@@ -591,190 +1136,672 @@ export async function acquireDaemonOwnership(input: {
591
1136
  const pid = input.pid ?? process.pid;
592
1137
  if (!validDaemonPid(pid)) return { acquired: false, attached: true };
593
1138
  const pidAlive = input.pidAlive ?? defaultPidAlive;
1139
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1140
+ const incarnation = pidIncarnation(pid);
1141
+ if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, provisional: true };
594
1142
  const paths = daemonPaths(input.settings.getAgentDir());
595
1143
  await ensureDir(fsImpl, paths.dir);
596
1144
  const ownerId =
597
1145
  input.ownerId ?? input.randomId?.() ?? `${pid}-${now().toString(36)}-${Math.random().toString(36).slice(2)}`;
598
1146
  const roots = input.roots ?? (await readJson<{ roots?: string[] }>(fsImpl, paths.roots))?.roots ?? [];
599
- return withFileLock(paths.ownership, async () => {
600
- const lockIsPositivelyStale = async (): Promise<boolean> => {
601
- let initialization: { pid?: unknown; startedAt?: unknown } | undefined;
602
- try {
603
- initialization = await readJson<{ pid?: unknown; startedAt?: unknown }>(fsImpl, paths.lock);
604
- } catch {
605
- // Legacy daemons wrote an empty lock file. With no live state to
606
- // identify an owner, malformed metadata must not wedge startup.
607
- return true;
608
- }
609
- if (!initialization) return true;
1147
+
1148
+ // A fresh, identity-matching live owner running an OLDER generation than this
1149
+ // build cannot serve our newer wire frames; signal a reload instead of a
1150
+ // silent attach. Newer/equal generations attach as before (no downgrade).
1151
+ const attachDecision = (
1152
+ state: DaemonState | undefined,
1153
+ ):
1154
+ | { acquired: false; attached: boolean; blocked?: boolean; provisional?: boolean; reloadRequired?: boolean }
1155
+ | undefined => {
1156
+ if (state && !hasSafeDaemonStateShape(state)) {
1157
+ const malformed = state as Partial<DaemonState>;
610
1158
  if (
611
- !validDaemonPid(initialization.pid) ||
612
- typeof initialization.startedAt !== "number" ||
613
- !Number.isFinite(initialization.startedAt)
1159
+ !isExplicitlyStoppedDaemonState(malformed) &&
1160
+ validDaemonPid(malformed.pid) &&
1161
+ typeof malformed.incarnation === "string" &&
1162
+ pidAlive(malformed.pid)
614
1163
  )
615
- return true;
616
- return now() - initialization.startedAt > HEARTBEAT_TTL_MS && !pidAlive(initialization.pid);
617
- };
618
- const ownershipIsPositivelyStale = async (state: DaemonState | undefined): Promise<boolean> => {
619
- if (state?.stoppedAt !== undefined) return true;
620
- if (state && validDaemonPid(state.pid)) return !pidAlive(state.pid);
621
- return await lockIsPositivelyStale();
622
- };
623
- const writeState = async (): Promise<void> => {
624
- const timestamp = now();
625
- await writeJsonAtomic(fsImpl, paths.state, {
626
- pid,
627
- ownerId,
1164
+ return { acquired: false, attached: false, blocked: true };
1165
+ return undefined;
1166
+ }
1167
+ if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1168
+ return undefined;
1169
+ // Unavailable provenance is ambiguous and must remain fail-closed. A
1170
+ // different authoritative incarnation proves PID reuse, so allow only the
1171
+ // transition-locked path below to reclaim the stale owner artifacts.
1172
+ if (pidAlive(state.pid)) {
1173
+ const currentIncarnation = pidIncarnation(state.pid);
1174
+ if (!isProcessIncarnation(currentIncarnation) || !isProcessIncarnation(state.incarnation))
1175
+ return { acquired: false, attached: false, blocked: true };
1176
+ if (currentIncarnation !== state.incarnation) return undefined;
1177
+ }
1178
+ if (!pidAlive(state.pid)) return undefined;
1179
+ if (
1180
+ isCurrentCompatibleOwner({
1181
+ state,
1182
+ now: now(),
628
1183
  tokenFingerprint: input.tokenFingerprint,
629
1184
  chatId: input.chatId,
630
- startedAt: timestamp,
631
- heartbeatAt: timestamp,
632
- roots,
633
- version: DAEMON_VERSION,
634
- generation: DAEMON_GENERATION,
635
- ...(input.allowPidRebind ? { launcherPid: pid } : {}),
636
- } satisfies DaemonState);
637
- };
638
- const attachDecision = (
639
- state: DaemonState | undefined,
640
- ): { acquired: false; attached: boolean; reloadRequired?: boolean } | undefined => {
641
- if (
642
- !isFreshLiveOwner({
643
- state,
644
- now: now(),
645
- tokenFingerprint: input.tokenFingerprint,
646
- chatId: input.chatId,
647
- pidAlive,
648
- })
649
- ) {
650
- return undefined;
651
- }
652
- return (state?.generation ?? 0) < DAEMON_GENERATION
653
- ? { acquired: false, attached: false, reloadRequired: true }
654
- : { acquired: false, attached: true };
655
- };
656
- const rejectLiveDifferentIdentity = (state: DaemonState | undefined) =>
657
- liveOwnerUsesDifferentIdentity({
1185
+ pidAlive,
1186
+ pidIncarnation,
1187
+ })
1188
+ )
1189
+ return { acquired: false, attached: true };
1190
+ // A physical owner that cannot prove current compatibility is never safe to
1191
+ // attach. Legacy, stale, and malformed generations require a handoff;
1192
+ // otherwise retain the explicit provisional classification.
1193
+ if (
1194
+ state.version !== DAEMON_VERSION ||
1195
+ !isFreshLiveOwner({
658
1196
  state,
1197
+ now: now(),
659
1198
  tokenFingerprint: input.tokenFingerprint,
660
1199
  chatId: input.chatId,
661
1200
  pidAlive,
1201
+ pidIncarnation,
1202
+ }) ||
1203
+ isRecognizedLegacyGeneration(state.generation)
1204
+ )
1205
+ return { acquired: false, attached: false, reloadRequired: true };
1206
+ return { acquired: false, attached: false, provisional: true };
1207
+ };
1208
+ const existing = await readJson<DaemonState>(fsImpl, paths.state);
1209
+ const foreignOwner = classifyForeignLiveOwner({
1210
+ state: existing,
1211
+ tokenFingerprint: input.tokenFingerprint,
1212
+ chatId: input.chatId,
1213
+ pidAlive,
1214
+ pidIncarnation,
1215
+ });
1216
+ if (foreignOwner) {
1217
+ return foreignOwner === "identity_mismatch"
1218
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1219
+ : { acquired: false, attached: false, blocked: true };
1220
+ }
1221
+
1222
+ const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1223
+ if (!transition) return { acquired: false, attached: false, provisional: true };
1224
+ try {
1225
+ const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
1226
+ const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1227
+ const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
1228
+ if (
1229
+ recheckedDecision &&
1230
+ (recheckedDecision.attached ||
1231
+ recheckedDecision.blocked ||
1232
+ recheckedLock.kind === "missing" ||
1233
+ ownershipLockMatchesState(recheckedLock, rechecked))
1234
+ )
1235
+ return recheckedDecision;
1236
+ // A stopped tombstone authorizes removal only of its own canonical lock.
1237
+ // A newer initializer may have replaced the pathname while that tombstone
1238
+ // remained, and must receive the same liveness/freshness protection as any
1239
+ // other live reservation.
1240
+ const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
1241
+ const lockDecision = stoppedLockMatches
1242
+ ? undefined
1243
+ : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1244
+ if (lockDecision) return lockDecision;
1245
+ if (
1246
+ !stoppedLockMatches &&
1247
+ !(await ownershipLockIsReclaimable({
1248
+ fs: fsImpl,
1249
+ path: paths.lock,
1250
+ lock: recheckedLock,
1251
+ now: now(),
1252
+ pidAlive,
1253
+ pidIncarnation,
1254
+ }))
1255
+ )
1256
+ return { acquired: false, attached: false, provisional: true };
1257
+ if (isLegacyParentDaemonState(rechecked)) {
1258
+ const legacyDecision = await legacyParentHandoffDecision({
1259
+ fs: fsImpl,
1260
+ statePath: paths.state,
1261
+ state: rechecked,
1262
+ now: now(),
1263
+ pidAlive,
1264
+ pidIncarnation,
1265
+ tokenFingerprint: input.tokenFingerprint,
1266
+ chatId: input.chatId,
662
1267
  });
663
- const existing = await readJson<DaemonState>(fsImpl, paths.state);
664
- if (rejectLiveDifferentIdentity(existing)) {
665
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
1268
+ if (legacyDecision) return legacyDecision;
1269
+ } else if (recheckedDecision) {
1270
+ return recheckedDecision;
666
1271
  }
667
- const existingDecision = attachDecision(existing);
668
- if (existingDecision) return existingDecision;
669
- const createdAt = now();
670
- if (await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt })) {
671
- await writeState();
672
- return { acquired: true, ownerId };
1272
+ const recheckedForeignOwner = classifyForeignLiveOwner({
1273
+ state: rechecked,
1274
+ tokenFingerprint: input.tokenFingerprint,
1275
+ chatId: input.chatId,
1276
+ pidAlive,
1277
+ pidIncarnation,
1278
+ });
1279
+ if (recheckedForeignOwner) {
1280
+ return recheckedForeignOwner === "identity_mismatch"
1281
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1282
+ : { acquired: false, attached: false, blocked: true };
673
1283
  }
674
- const afterLock = await readJson<DaemonState>(fsImpl, paths.state);
675
- if (rejectLiveDifferentIdentity(afterLock)) {
676
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
1284
+ if (
1285
+ hasSafeDaemonStateShape(rechecked) &&
1286
+ rechecked.stoppedAt === undefined &&
1287
+ pidAlive(rechecked.pid) &&
1288
+ ownerProvenanceMatches(rechecked, pidIncarnation)
1289
+ )
1290
+ return { acquired: false, attached: false, provisional: true };
1291
+ const currentLock = await readOwnershipLock(fsImpl, paths.lock);
1292
+ if (!ownershipLockMatches(recheckedLock, currentLock))
1293
+ return { acquired: false, attached: false, provisional: true };
1294
+ if (currentLock.kind !== "missing") {
1295
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1296
+ return { acquired: false, attached: false, provisional: true };
1297
+ await fsImpl.unlink(paths.lock);
677
1298
  }
678
- const afterLockDecision = attachDecision(afterLock);
679
- if (afterLockDecision) return afterLockDecision;
680
- if (!afterLock && !(await lockIsPositivelyStale())) return { acquired: false, attached: true };
681
- if (!(await tryOpenWx(fsImpl, paths.steal))) return { acquired: false, attached: true };
1299
+ const ownershipLock: OwnershipLockMetadata = {
1300
+ pid,
1301
+ incarnation,
1302
+ ownerId,
1303
+ acquisitionId: ownerId,
1304
+ startedAt: now(),
1305
+ };
1306
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1307
+ return { acquired: false, attached: false, provisional: true };
1308
+ if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, ownershipLock)))
1309
+ return { acquired: false, attached: false, provisional: true };
1310
+ if (
1311
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1312
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1313
+ )
1314
+ return { acquired: false, attached: false, provisional: true };
1315
+ await writeJsonAtomic(fsImpl, paths.state, {
1316
+ pid,
1317
+ incarnation,
1318
+ ownerId,
1319
+ acquisitionId: ownerId,
1320
+ ownershipPhase: "provisional",
1321
+ tokenFingerprint: input.tokenFingerprint,
1322
+ chatId: input.chatId,
1323
+ startedAt: now(),
1324
+ heartbeatAt: now(),
1325
+ roots,
1326
+ version: DAEMON_VERSION,
1327
+ generation: DAEMON_GENERATION,
1328
+ } satisfies DaemonState);
1329
+ if (
1330
+ !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })) ||
1331
+ !ownershipLockMatchesMetadata(await readOwnershipLock(fsImpl, paths.lock), ownershipLock)
1332
+ )
1333
+ return { acquired: false, attached: false, provisional: true };
1334
+ return { acquired: true, ownerId, acquisitionId: ownerId };
1335
+ } finally {
1336
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1337
+ }
1338
+ }
1339
+
1340
+ export async function renewDaemonHeartbeat(input: {
1341
+ settings: Settings;
1342
+ ownerId: string;
1343
+ acquisitionId?: string;
1344
+ tokenFingerprint?: string;
1345
+ chatId?: string;
1346
+ fs?: TelegramDaemonFs;
1347
+ now?: () => number;
1348
+ pid?: number;
1349
+ generation?: number;
1350
+ pidIncarnation?: (pid: number) => string | undefined;
1351
+ sleep?: (ms: number) => Promise<void>;
1352
+ stealRetries?: number;
1353
+ stealRetryDelayMs?: number;
1354
+ }): Promise<boolean> {
1355
+ const fsImpl = input.fs ?? nodeFs;
1356
+ const paths = daemonPaths(input.settings.getAgentDir());
1357
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1358
+ // The steal lock is held only briefly by concurrent lifecycle operations.
1359
+ // A contended lock never proves readiness: only the holder may validate and
1360
+ // publish the exact ready PID/generation state.
1361
+ const transition = await acquireTransitionLock({
1362
+ fs: fsImpl,
1363
+ path: paths.steal,
1364
+ pidIncarnation: input.pidIncarnation,
1365
+ sleep: input.sleep,
1366
+ retries: input.stealRetries,
1367
+ retryDelayMs: input.stealRetryDelayMs,
1368
+ });
1369
+ if (!transition) return false;
1370
+ try {
1371
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1372
+ const pid = input.pid ?? state?.pid;
1373
+ const generation = input.generation ?? state?.generation;
1374
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
1375
+ // A daemon child may atomically bind its own PID only while its launcher
1376
+ // reservation is still provisional and carries the same acquisition secret.
1377
+ const canBindProvisionalPid =
1378
+ state?.ownershipPhase === "provisional" &&
1379
+ state.pid !== pid &&
1380
+ state.ownerId === input.ownerId &&
1381
+ state.acquisitionId === acquisitionId;
1382
+ if (
1383
+ !state ||
1384
+ !hasSafeDaemonStateShape(state) ||
1385
+ typeof pid !== "number" ||
1386
+ !Number.isSafeInteger(pid) ||
1387
+ pid <= 0 ||
1388
+ !isProcessIncarnation(incarnation) ||
1389
+ generation !== DAEMON_GENERATION ||
1390
+ state.ownerId !== input.ownerId ||
1391
+ state.acquisitionId !== acquisitionId ||
1392
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1393
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1394
+ (!canBindProvisionalPid && state.incarnation !== incarnation) ||
1395
+ (!canBindProvisionalPid && state.pid !== pid) ||
1396
+ state.generation !== generation ||
1397
+ state.stoppedAt !== undefined ||
1398
+ state.ownershipPhase === "retired"
1399
+ )
1400
+ return false;
1401
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1402
+ const previousLock = canBindProvisionalPid ? await readOwnershipLock(fsImpl, paths.lock) : undefined;
1403
+ const expectedLock =
1404
+ previousLock?.kind === "valid" &&
1405
+ previousLock.metadata.pid === state.pid &&
1406
+ previousLock.metadata.incarnation === state.incarnation &&
1407
+ previousLock.metadata.ownerId === state.ownerId &&
1408
+ previousLock.metadata.acquisitionId === state.acquisitionId
1409
+ ? previousLock.metadata
1410
+ : undefined;
1411
+ const reboundLock = expectedLock ? { ...expectedLock, pid, incarnation } : undefined;
1412
+ if (
1413
+ canBindProvisionalPid &&
1414
+ (!expectedLock ||
1415
+ !reboundLock ||
1416
+ !(await rebindOwnershipLock({
1417
+ fs: fsImpl,
1418
+ path: paths.lock,
1419
+ transitionPath: paths.steal,
1420
+ transition,
1421
+
1422
+ expected: expectedLock,
1423
+ rebound: reboundLock,
1424
+ })))
1425
+ )
1426
+ return false;
682
1427
  try {
683
- const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
684
- const recheckedDecision = attachDecision(rechecked);
685
- if (recheckedDecision) return recheckedDecision;
686
- if (rejectLiveDifferentIdentity(rechecked)) {
687
- return { acquired: false, blocked: true, reason: "identity_mismatch" } as const;
688
- }
689
- if (
690
- rechecked &&
691
- rechecked.stoppedAt === undefined &&
692
- validDaemonPid(rechecked.pid) &&
693
- pidAlive(rechecked.pid)
694
- ) {
695
- return { acquired: false, attached: true };
696
- }
697
- if (!rechecked && !(await lockIsPositivelyStale())) {
698
- return { acquired: false, attached: true };
699
- }
700
- if (!(await ownershipIsPositivelyStale(rechecked))) {
701
- return { acquired: false, attached: true };
702
- }
703
- await fsImpl.unlink(paths.lock).catch(() => undefined);
704
- if (!(await tryCreateOwnershipLock(fsImpl, paths.lock, { pid, startedAt: createdAt }))) {
705
- return { acquired: false, attached: true };
706
- }
707
- await writeState();
708
- return { acquired: true, ownerId };
709
- } finally {
710
- await fsImpl.unlink(paths.steal).catch(() => undefined);
1428
+ await writeJsonAtomic(fsImpl, paths.state, {
1429
+ ...state,
1430
+ // Preserve the source launcher PID so concurrent ensures can recognize
1431
+ // this ready PID as its child rather than excluding it as the launcher.
1432
+ launcherPid: state.pid,
1433
+ pid,
1434
+ incarnation,
1435
+ ownershipPhase: "ready",
1436
+ heartbeatAt: (input.now ?? Date.now)(),
1437
+ });
1438
+ } catch {
1439
+ if (expectedLock && reboundLock)
1440
+ await rollbackOwnershipLockRebind({
1441
+ fs: fsImpl,
1442
+ path: paths.lock,
1443
+ transitionPath: paths.steal,
1444
+ transition,
1445
+
1446
+ previous: expectedLock,
1447
+ rebound: reboundLock,
1448
+ });
1449
+ return false;
711
1450
  }
1451
+ return true;
1452
+ } finally {
1453
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1454
+ }
1455
+ }
1456
+
1457
+ /** Acquire the lifecycle transition lock with bounded retry for bind/retire races. */
1458
+ export async function acquireTransitionLock(input: {
1459
+ fs: TelegramDaemonFs;
1460
+ path: string;
1461
+ pidAlive?: (pid: number) => boolean;
1462
+ pidIncarnation?: (pid: number) => string | undefined;
1463
+ sleep?: (ms: number) => Promise<void>;
1464
+ retries?: number;
1465
+ retryDelayMs?: number;
1466
+ }): Promise<DaemonTransitionLock | undefined> {
1467
+ return await acquireDaemonTransitionLock({
1468
+ fs: input.fs,
1469
+ path: input.path,
1470
+ pid: process.pid,
1471
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1472
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1473
+ sleep: input.sleep,
1474
+ retries: input.retries,
1475
+ retryDelayMs: input.retryDelayMs,
712
1476
  });
713
1477
  }
714
1478
 
715
- export async function renewDaemonHeartbeat(input: {
1479
+ /** Retire only the unchanged provisional acquisition after bounded readiness fails. */
1480
+ export async function retireProvisionalDaemonOwnership(input: {
716
1481
  settings: Settings;
717
1482
  ownerId: string;
718
- /** Bind a daemon heartbeat to the Telegram identity that acquired the lock. */
719
- tokenFingerprint: string;
720
- chatId: string;
721
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
1483
+ acquisitionId?: string;
1484
+ pidIncarnation?: (pid: number) => string | undefined;
1485
+ pidAlive?: (pid: number) => boolean;
1486
+ /** Detached child PID, when the launcher successfully reported it. */
722
1487
  pid: number;
1488
+ /** PID which created the provisional reservation before the child was bound. */
1489
+ launcherPid?: number;
723
1490
  fs?: TelegramDaemonFs;
724
1491
  now?: () => number;
1492
+ sleep?: (ms: number) => Promise<void>;
1493
+ stealRetries?: number;
1494
+ stealRetryDelayMs?: number;
1495
+ /** Only no-child confirmation may retire a ready-like launcher publication. */
1496
+ allowReadyWithoutChildPid?: boolean;
725
1497
  }): Promise<boolean> {
726
1498
  const fsImpl = input.fs ?? nodeFs;
727
1499
  const paths = daemonPaths(input.settings.getAgentDir());
728
- return withFileLock(paths.ownership, async () => {
1500
+ const transition = await acquireTransitionLock({
1501
+ fs: fsImpl,
1502
+ path: paths.steal,
1503
+ pidAlive: input.pidAlive,
1504
+ pidIncarnation: input.pidIncarnation,
1505
+ sleep: input.sleep,
1506
+ retries: input.stealRetries,
1507
+ retryDelayMs: input.stealRetryDelayMs,
1508
+ });
1509
+ if (!transition) return false;
1510
+ try {
729
1511
  const state = await readJson<DaemonState>(fsImpl, paths.state);
1512
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1513
+ const expectedPid = state?.pid === input.pid ? input.pid : input.launcherPid;
1514
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1515
+ const expectedPidAlive = expectedPid !== undefined && pidAlive(expectedPid);
1516
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(expectedPid ?? 0);
730
1517
  if (
731
1518
  !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
1519
  state.ownerId !== input.ownerId ||
738
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId)
1520
+ state.acquisitionId !== acquisitionId ||
1521
+ (expectedPidAlive &&
1522
+ (!isProcessIncarnation(incarnation) ||
1523
+ !isProcessIncarnation(state.incarnation) ||
1524
+ state.incarnation !== incarnation)) ||
1525
+ (state.pid !== input.pid && state.pid !== input.launcherPid) ||
1526
+ state.generation !== DAEMON_GENERATION ||
1527
+ !Number.isSafeInteger(state.generation) ||
1528
+ (state.ownershipPhase !== "provisional" &&
1529
+ !(input.allowReadyWithoutChildPid === true && state.ownershipPhase === "ready"))
739
1530
  )
740
1531
  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;
1532
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
744
1533
  await writeJsonAtomic(fsImpl, paths.state, {
745
1534
  ...state,
746
- pid: input.pid,
747
- heartbeatAt: (input.now ?? Date.now)(),
1535
+ ownershipPhase: "retired",
1536
+ stoppedAt: (input.now ?? Date.now)(),
748
1537
  });
1538
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1539
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
749
1540
  return true;
750
- });
1541
+ } finally {
1542
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1543
+ }
751
1544
  }
752
1545
 
753
- export async function releaseDaemonOwnership(input: {
1546
+ /** Bind a launcher-reserved provisional acquisition to its actual detached child. */
1547
+ async function bindProvisionalDaemonPid(input: {
754
1548
  settings: Settings;
755
1549
  ownerId: string;
756
- /** Bind release to the Telegram identity that acquired the lock. */
1550
+ acquisitionId: string;
1551
+ pid: number;
1552
+ incarnation: string;
1553
+ fs?: TelegramDaemonFs;
1554
+ sleep?: (ms: number) => Promise<void>;
1555
+ stealRetries?: number;
1556
+ stealRetryDelayMs?: number;
1557
+ }): Promise<boolean> {
1558
+ const fsImpl = input.fs ?? nodeFs;
1559
+ const paths = daemonPaths(input.settings.getAgentDir());
1560
+ const transition = await acquireTransitionLock({
1561
+ fs: fsImpl,
1562
+ path: paths.steal,
1563
+ sleep: input.sleep,
1564
+ retries: input.stealRetries,
1565
+ retryDelayMs: input.stealRetryDelayMs,
1566
+ });
1567
+ if (!transition) return false;
1568
+ try {
1569
+ const state = await readJson<DaemonState>(fsImpl, paths.state);
1570
+ if (
1571
+ !state ||
1572
+ state.ownerId !== input.ownerId ||
1573
+ state.acquisitionId !== input.acquisitionId ||
1574
+ state.ownershipPhase !== "provisional" ||
1575
+ state.generation !== DAEMON_GENERATION ||
1576
+ !isProcessIncarnation(state.incarnation) ||
1577
+ !isProcessIncarnation(input.incarnation)
1578
+ )
1579
+ return false;
1580
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
1581
+ const previousLock = await readOwnershipLock(fsImpl, paths.lock);
1582
+ if (
1583
+ previousLock.kind !== "valid" ||
1584
+ previousLock.metadata.pid !== state.pid ||
1585
+ previousLock.metadata.incarnation !== state.incarnation ||
1586
+ previousLock.metadata.ownerId !== state.ownerId ||
1587
+ previousLock.metadata.acquisitionId !== state.acquisitionId
1588
+ )
1589
+ return false;
1590
+ const reboundLock = { ...previousLock.metadata, pid: input.pid, incarnation: input.incarnation };
1591
+ if (
1592
+ !(await rebindOwnershipLock({
1593
+ fs: fsImpl,
1594
+ path: paths.lock,
1595
+ transitionPath: paths.steal,
1596
+ transition,
1597
+
1598
+ expected: previousLock.metadata,
1599
+ rebound: reboundLock,
1600
+ }))
1601
+ )
1602
+ return false;
1603
+ try {
1604
+ await writeJsonAtomic(fsImpl, paths.state, {
1605
+ ...state,
1606
+ // This durable marker distinguishes a launcher reservation from a PID
1607
+ // the launcher authoritatively rebound to its child.
1608
+ launcherPid: state.launcherPid ?? state.pid,
1609
+ pid: input.pid,
1610
+ incarnation: input.incarnation,
1611
+ });
1612
+ } catch {
1613
+ await rollbackOwnershipLockRebind({
1614
+ fs: fsImpl,
1615
+ path: paths.lock,
1616
+ transitionPath: paths.steal,
1617
+ transition,
1618
+ previous: previousLock.metadata,
1619
+ rebound: reboundLock,
1620
+ });
1621
+ return false;
1622
+ }
1623
+ return true;
1624
+ } finally {
1625
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1626
+ }
1627
+ }
1628
+
1629
+ /** Wait for a matching current-generation daemon to publish a ready state. */
1630
+ export async function waitForTelegramDaemonReady(input: {
1631
+ settings: Settings;
1632
+ ownerId?: string;
1633
+ acquisitionId?: string;
1634
+ pid?: number;
1635
+ excludedPid?: number;
1636
+ tokenFingerprint: string;
1637
+ chatId: string;
1638
+ fs?: TelegramDaemonFs;
1639
+ now?: () => number;
1640
+ pidAlive?: (pid: number) => boolean;
1641
+ pidIncarnation?: (pid: number) => string | undefined;
1642
+ sleep?: (ms: number) => Promise<void>;
1643
+ waitStepMs?: number;
1644
+ timeoutMs?: number;
1645
+ }): Promise<boolean> {
1646
+ const now = input.now ?? Date.now;
1647
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1648
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
1649
+ const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
1650
+ const timeoutMs = Math.max(input.timeoutMs ?? 8_000, 0);
1651
+ const waitStepMs = Math.max(input.waitStepMs ?? 25, 1);
1652
+ const deadline = now() + timeoutMs;
1653
+ const maxPolls = Math.ceil(timeoutMs / waitStepMs);
1654
+ for (let poll = 0; poll <= maxPolls; poll++) {
1655
+ const state = await readDaemonState(input.settings, input.fs);
1656
+ if (
1657
+ (!input.ownerId || state?.ownerId === input.ownerId) &&
1658
+ (!input.acquisitionId || state?.acquisitionId === input.acquisitionId) &&
1659
+ state?.ownershipPhase === "ready" &&
1660
+ state.generation === DAEMON_GENERATION &&
1661
+ ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1662
+ ownerProvenanceMatches(state, pidIncarnation) &&
1663
+ pidAlive(state.pid) &&
1664
+ (Number.isSafeInteger(input.pid) && (input.pid as number) > 0
1665
+ ? state.pid === input.pid
1666
+ : !Number.isSafeInteger(input.excludedPid) || state.pid !== input.excludedPid) &&
1667
+ isCurrentCompatibleOwner({
1668
+ state,
1669
+ now: now(),
1670
+ tokenFingerprint: input.tokenFingerprint,
1671
+ chatId: input.chatId,
1672
+ pidAlive,
1673
+ pidIncarnation,
1674
+ })
1675
+ )
1676
+ return true;
1677
+ if (now() >= deadline || poll === maxPolls) break;
1678
+ await sleep(waitStepMs);
1679
+ }
1680
+ return false;
1681
+ }
1682
+
1683
+ /** Confirm the provisional owner or retire only its unchanged acquisition. */
1684
+ export async function confirmTelegramDaemonSpawn(input: {
1685
+ settings: Settings;
1686
+ spawned: TelegramSpawnOwnerResult;
757
1687
  tokenFingerprint: string;
758
1688
  chatId: string;
759
- /** Require the current daemon PID after the launcher-to-daemon PID rebind. */
760
1689
  pid: number;
761
1690
  fs?: TelegramDaemonFs;
762
1691
  now?: () => number;
1692
+ pidAlive?: (pid: number) => boolean;
1693
+ pidIncarnation?: (pid: number) => string | undefined;
1694
+ sleep?: (ms: number) => Promise<void>;
1695
+ waitStepMs?: number;
1696
+ timeoutMs?: number;
1697
+ }): Promise<boolean> {
1698
+ if (input.spawned.result !== "owner_spawned") return true;
1699
+ const childPid = input.spawned.acquisition.pid;
1700
+ const hasExactChildPid = Number.isSafeInteger(childPid) && (childPid as number) > 0;
1701
+ const ready = await waitForTelegramDaemonReady({
1702
+ settings: input.settings,
1703
+ ownerId: input.spawned.acquisition.ownerId,
1704
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1705
+ pid: hasExactChildPid ? childPid : undefined,
1706
+ excludedPid: hasExactChildPid ? undefined : input.pid,
1707
+ tokenFingerprint: input.tokenFingerprint,
1708
+ chatId: input.chatId,
1709
+ fs: input.fs,
1710
+ now: input.now,
1711
+ pidAlive: input.pidAlive,
1712
+ pidIncarnation: input.pidIncarnation,
1713
+ sleep: input.sleep,
1714
+ waitStepMs: input.waitStepMs,
1715
+ timeoutMs: input.timeoutMs,
1716
+ });
1717
+ if (ready) return true;
1718
+ const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
1719
+ const retired = await retireProvisionalDaemonOwnership({
1720
+ settings: input.settings,
1721
+ ownerId: input.spawned.acquisition.ownerId,
1722
+ acquisitionId: input.spawned.acquisition.acquisitionId,
1723
+ pid: hasExactChildPid ? (childPid as number) : launcherPid,
1724
+ launcherPid,
1725
+ allowReadyWithoutChildPid: !hasExactChildPid,
1726
+ fs: input.fs,
1727
+ now: input.now,
1728
+ pidAlive: input.pidAlive,
1729
+ pidIncarnation: input.pidIncarnation,
1730
+ sleep: input.sleep,
1731
+ });
1732
+ if (retired) return false;
1733
+ const state = await readDaemonState(input.settings, input.fs);
1734
+ if (
1735
+ hasExactChildPid &&
1736
+ isCurrentCompatibleOwner({
1737
+ state,
1738
+ now: (input.now ?? Date.now)(),
1739
+ tokenFingerprint: input.tokenFingerprint,
1740
+ chatId: input.chatId,
1741
+ pidAlive: input.pidAlive ?? defaultPidAlive,
1742
+ pidIncarnation: input.pidIncarnation ?? defaultPidIncarnation,
1743
+ }) &&
1744
+ (state?.ownerId !== input.spawned.acquisition.ownerId ||
1745
+ state.acquisitionId !== input.spawned.acquisition.acquisitionId ||
1746
+ state.pid === childPid)
1747
+ )
1748
+ return true;
1749
+ if (
1750
+ state?.ownerId === input.spawned.acquisition.ownerId &&
1751
+ state.acquisitionId === input.spawned.acquisition.acquisitionId &&
1752
+ (state.pid === childPid || state.pid === launcherPid || state.pid === input.pid) &&
1753
+ state.ownershipPhase === "retired"
1754
+ )
1755
+ return false;
1756
+ throw new Error("Telegram daemon provisional ownership could not be retired safely");
1757
+ }
1758
+
1759
+ export async function releaseDaemonOwnership(input: {
1760
+ settings: Settings;
1761
+ ownerId: string;
1762
+ acquisitionId?: string;
1763
+ tokenFingerprint?: string;
1764
+ chatId?: string;
1765
+ pid?: number;
1766
+ generation?: number;
1767
+ pidIncarnation?: (pid: number) => string | undefined;
1768
+ fs?: TelegramDaemonFs;
1769
+ now?: () => number;
763
1770
  }): Promise<void> {
764
1771
  const fsImpl = input.fs ?? nodeFs;
765
1772
  const paths = daemonPaths(input.settings.getAgentDir());
766
- await withFileLock(paths.ownership, async () => {
1773
+ const transition = await acquireTransitionLock({
1774
+ fs: fsImpl,
1775
+ path: paths.steal,
1776
+ pidIncarnation: input.pidIncarnation,
1777
+ });
1778
+ if (!transition) return;
1779
+ try {
767
1780
  const state = await readJson<DaemonState>(fsImpl, paths.state);
1781
+ const acquisitionId = input.acquisitionId ?? input.ownerId;
1782
+ const pid = input.pid ?? state?.pid;
1783
+ const generation = input.generation ?? state?.generation;
1784
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(pid ?? 0);
768
1785
  if (
769
- !state ||
1786
+ !hasSafeDaemonStateShape(state) ||
770
1787
  state.ownerId !== input.ownerId ||
771
- !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
772
- state.pid !== input.pid
1788
+ state.acquisitionId !== acquisitionId ||
1789
+ (input.tokenFingerprint !== undefined && state.tokenFingerprint !== input.tokenFingerprint) ||
1790
+ (input.chatId !== undefined && state.chatId !== input.chatId) ||
1791
+ state.pid !== pid ||
1792
+ !isProcessIncarnation(incarnation) ||
1793
+ state.incarnation !== incarnation ||
1794
+ state.generation !== generation ||
1795
+ !Number.isSafeInteger(generation)
773
1796
  )
774
1797
  return;
1798
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
775
1799
  await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
776
- await fsImpl.unlink(paths.lock).catch(() => undefined);
777
- });
1800
+ if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1801
+ await fsImpl.unlink(paths.lock).catch(() => undefined);
1802
+ } finally {
1803
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1804
+ }
778
1805
  }
779
1806
 
780
1807
  /** Read the persisted daemon ownership state (or undefined when absent). */
@@ -803,6 +1830,15 @@ function defaultPidAlive(pid: number): boolean {
803
1830
  }
804
1831
  }
805
1832
 
1833
+ /**
1834
+ * Use immutable process-start provenance on every supported OS. In particular,
1835
+ * Windows is authorized by the native process API or PowerShell StartTime/FileTime;
1836
+ * absent authority fails closed and never trusts the numeric PID alone.
1837
+ */
1838
+ function defaultPidIncarnation(pid: number): string | undefined {
1839
+ return processIncarnation(pid);
1840
+ }
1841
+
806
1842
  /** True for AbortError-shaped rejections raised when an in-flight fetch is aborted. */
807
1843
  function isAbortError(err: unknown): boolean {
808
1844
  return err instanceof Error && (err.name === "AbortError" || /\baborted\b/i.test(err.message));
@@ -828,7 +1864,7 @@ function defaultDaemonSpawn(
828
1864
  const child = childProcessSpawn(command, args, { detached: opts.detached, stdio });
829
1865
  // Best-effort autostart: a spawn failure must never crash the host session.
830
1866
  child.on("error", () => undefined);
831
- return { unref: () => child.unref() };
1867
+ return { pid: child.pid, unref: () => child.unref() };
832
1868
  }
833
1869
 
834
1870
  export interface TelegramSpawnOwnerInput {
@@ -838,19 +1874,20 @@ export interface TelegramSpawnOwnerInput {
838
1874
  chatId: string;
839
1875
  }
840
1876
 
841
- export interface TelegramSpawnOwnerResult {
842
- result: EnsureDaemonResult;
843
- ownerId?: string;
844
- runtime: DaemonRuntimeInfo;
845
- warnings: string[];
846
- /**
847
- * Set when ownership was NOT acquired because a still-live owner is running an
848
- * older daemon generation. The caller must hand off via a reload rather than
849
- * attach; see {@link ensureTelegramDaemonRunning}.
850
- */
851
- reloadRequired?: boolean;
1877
+ export interface TelegramSpawnAcquisition {
1878
+ readonly ownerId: string;
1879
+ readonly acquisitionId: string;
1880
+ /** PID of the launcher which reserved provisional ownership before spawn. */
1881
+ readonly launcherPid?: number;
1882
+ /** Actual detached child which must publish the ready owner state. */
1883
+ readonly pid?: number;
852
1884
  }
853
1885
 
1886
+ export type TelegramSpawnOwnerResult =
1887
+ | { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
1888
+ | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
1889
+ | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
1890
+
854
1891
  /**
855
1892
  * Build the detached spawn command/args for the daemon-internal entrypoint.
856
1893
  * Source mode prepends the entry script so the respawn loads edited source;
@@ -910,26 +1947,41 @@ export async function spawnTelegramDaemonOwner(
910
1947
  now: deps.now,
911
1948
  pid: deps.pid,
912
1949
  pidAlive: deps.pidAlive,
1950
+ pidIncarnation: deps.pidIncarnation,
913
1951
  randomId: ownerId ? undefined : deps.randomId,
914
1952
  ownerId,
915
- allowPidRebind: true,
916
- });
917
- // Build args from the shared runtime resolver.
918
- const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
919
- execPath,
920
- ownerId: ownership.ownerId ?? "",
921
- agentDir,
922
1953
  });
923
1954
  if (!ownership.acquired) {
924
- if (ownership.blocked) {
1955
+ if (ownership.blocked || ownership.provisional) {
925
1956
  return {
926
1957
  result: "blocked",
927
- runtime,
928
- warnings: ["live telegram daemon uses a different bot token or chat; refusing to attach"],
1958
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
1959
+ warnings: [
1960
+ ownership.provisional
1961
+ ? "telegram daemon ownership is provisional; refusing to attach"
1962
+ : "live telegram daemon uses a different bot token or chat; refusing to attach",
1963
+ ],
929
1964
  };
930
1965
  }
931
- return { result: "attached", runtime, warnings: [], reloadRequired: ownership.reloadRequired };
1966
+ return {
1967
+ result: "attached",
1968
+ runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
1969
+ warnings: [],
1970
+ reloadRequired: ownership.reloadRequired,
1971
+ };
932
1972
  }
1973
+ const launcherPid = deps.pid ?? process.pid;
1974
+ const provisionalAcquisition: TelegramSpawnAcquisition = Object.freeze({
1975
+ ownerId: ownership.ownerId as string,
1976
+ acquisitionId: ownership.acquisitionId as string,
1977
+ launcherPid,
1978
+ });
1979
+ // One source of truth for runtime detection + spawn args (no duplicate resolve).
1980
+ const { command, args, runtime } = buildTelegramDaemonSpawnArgs({
1981
+ execPath,
1982
+ ownerId: provisionalAcquisition.ownerId,
1983
+ agentDir,
1984
+ });
933
1985
  const spawnImpl = deps.spawn ?? defaultDaemonSpawn;
934
1986
  const child = spawnImpl(command, args, {
935
1987
  detached: true,
@@ -937,7 +1989,25 @@ export async function spawnTelegramDaemonOwner(
937
1989
  logPath: path.join(daemonPaths(agentDir).dir, "daemon.log"),
938
1990
  });
939
1991
  child?.unref?.();
940
- return { result: "owner_spawned", ownerId: ownership.ownerId, runtime, warnings: [] };
1992
+ // A launcher can reserve ownership, but only the actual child can become
1993
+ // ready. Missing PID provenance is intentionally non-ready and times out.
1994
+ const childPid = child?.pid;
1995
+ if (!Number.isSafeInteger(childPid) || (childPid as number) <= 0)
1996
+ return { result: "owner_spawned", acquisition: provisionalAcquisition, runtime, warnings: [] };
1997
+ const pid = childPid as number;
1998
+ const acquisition: TelegramSpawnAcquisition = Object.freeze({ ...provisionalAcquisition, pid });
1999
+ const incarnation = (deps.pidIncarnation ?? defaultPidIncarnation)(pid);
2000
+ if (!incarnation) return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2001
+ await bindProvisionalDaemonPid({
2002
+ settings: input.settings,
2003
+ ownerId: acquisition.ownerId,
2004
+ acquisitionId: acquisition.acquisitionId,
2005
+ incarnation,
2006
+ pid,
2007
+ fs: deps.fs,
2008
+ sleep: deps.sleep,
2009
+ });
2010
+ return { result: "owner_spawned", acquisition, runtime, warnings: [] };
941
2011
  }
942
2012
 
943
2013
  /**
@@ -950,25 +2020,94 @@ export async function ensureTelegramDaemonRunningDetailed(
950
2020
  ): Promise<EnsureTelegramDaemonDetailedResult> {
951
2021
  const cfg = getNotificationConfig(input.settings);
952
2022
  if (!isTelegramConfigured(cfg)) return "disabled";
953
- return await withTelegramSetupLease(cfg.botToken, async () => {
954
- const root = notificationRootForCwd(input.cwd);
955
- const fp = tokenFingerprint(cfg.botToken);
956
- const spawned = await spawnTelegramDaemonOwner(
957
- { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
958
- deps,
959
- );
960
- if (spawned.result === "blocked") {
961
- logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
962
- return "blocked_identity";
2023
+ const root = notificationRootForCwd(input.cwd);
2024
+ const fp = tokenFingerprint(cfg.botToken);
2025
+ let spawned = await withTelegramSetupLease(
2026
+ cfg.botToken,
2027
+ async () =>
2028
+ await spawnTelegramDaemonOwner(
2029
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2030
+ deps,
2031
+ ),
2032
+ );
2033
+ if (spawned.result === "blocked" && spawned.warnings[0]?.includes("provisional")) {
2034
+ const provisional = await readDaemonState(input.settings, deps.fs);
2035
+ // A launcher-reserved PID can be rebound by the child heartbeat. Only a
2036
+ // state marked by bindProvisionalDaemonPid has an authoritative child PID.
2037
+ const hasAuthoritativeChildPid =
2038
+ Number.isSafeInteger(provisional?.launcherPid) && (provisional?.launcherPid as number) > 0;
2039
+ const ready = await waitForTelegramDaemonReady({
2040
+ settings: input.settings,
2041
+ ownerId: provisional?.ownerId,
2042
+ acquisitionId: provisional?.acquisitionId,
2043
+ pid: hasAuthoritativeChildPid ? provisional?.pid : undefined,
2044
+ excludedPid: hasAuthoritativeChildPid ? undefined : provisional?.pid,
2045
+ tokenFingerprint: fp,
2046
+ chatId: cfg.chatId,
2047
+ fs: deps.fs,
2048
+ now: deps.now,
2049
+ pidAlive: deps.pidAlive,
2050
+ pidIncarnation: deps.pidIncarnation,
2051
+ sleep: deps.sleep,
2052
+ waitStepMs: deps.waitStepMs,
2053
+ timeoutMs: deps.readinessTimeoutMs,
2054
+ });
2055
+ if (ready) {
2056
+ spawned = await withTelegramSetupLease(
2057
+ cfg.botToken,
2058
+ async () =>
2059
+ await spawnTelegramDaemonOwner(
2060
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2061
+ deps,
2062
+ ),
2063
+ );
963
2064
  }
964
- if (spawned.reloadRequired) {
965
- await registerNotificationRoot({ ...input, fs: deps.fs });
966
- await reloadStaleGenerationOwner(input.settings, deps);
967
- return "reloaded";
2065
+ }
2066
+ if (spawned.result === "blocked") {
2067
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2068
+ return "blocked_identity";
2069
+ }
2070
+ if (spawned.result === "attached" && spawned.reloadRequired) {
2071
+ const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2072
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2073
+ const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2074
+ const upgrade = await controller.reloadForGenerationUpgrade();
2075
+ if (upgrade.outcome !== "ready") {
2076
+ await restoreNotificationRootRegistration({
2077
+ settings: input.settings,
2078
+ sessionId: input.sessionId,
2079
+ registeredRoot: root,
2080
+ previous,
2081
+ fs: deps.fs,
2082
+ });
2083
+ throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
968
2084
  }
2085
+ return "reloaded";
2086
+ }
2087
+ if (spawned.result !== "owner_spawned") {
969
2088
  await registerNotificationRoot({ ...input, fs: deps.fs });
970
- return spawned.result === "owner_spawned" ? "spawned" : "attached";
971
- });
2089
+ return "attached";
2090
+ }
2091
+ if (
2092
+ await confirmTelegramDaemonSpawn({
2093
+ settings: input.settings,
2094
+ spawned,
2095
+ tokenFingerprint: fp,
2096
+ chatId: cfg.chatId,
2097
+ pid: deps.pid ?? process.pid,
2098
+ fs: deps.fs,
2099
+ now: deps.now,
2100
+ pidAlive: deps.pidAlive,
2101
+ pidIncarnation: deps.pidIncarnation,
2102
+ sleep: deps.sleep,
2103
+ waitStepMs: deps.waitStepMs,
2104
+ timeoutMs: deps.readinessTimeoutMs,
2105
+ })
2106
+ ) {
2107
+ await registerNotificationRoot({ ...input, fs: deps.fs });
2108
+ return "spawned";
2109
+ }
2110
+ throw new Error("Telegram daemon did not become ready after spawning");
972
2111
  }
973
2112
 
974
2113
  /**
@@ -993,26 +2132,21 @@ export async function ensureTelegramDaemonRunning(
993
2132
  }
994
2133
  }
995
2134
 
996
- /**
997
- * Reload a still-live owner running an older daemon generation through the
998
- * cooperative SIGTERM/control handoff. Lazily imports the controller to avoid a
999
- * static import cycle (the controller module imports ownership helpers here).
1000
- */
1001
- async function reloadStaleGenerationOwner(settings: Settings, deps: TelegramDaemonDeps): Promise<void> {
1002
- const { TelegramDaemonController } = await import("./telegram-daemon-control");
1003
- const controller = new TelegramDaemonController(settings, {
2135
+ function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
2136
+ return {
1004
2137
  fs: deps.fs,
1005
2138
  now: deps.now,
1006
2139
  pidAlive: deps.pidAlive,
1007
- sendSignal: deps.sendSignal,
2140
+ processReference: deps.processReference,
2141
+ pidIncarnation: deps.pidIncarnation,
1008
2142
  spawn: deps.spawn,
1009
2143
  execPath: deps.execPath,
1010
2144
  ownerPid: deps.pid,
1011
2145
  randomId: deps.randomId,
1012
2146
  sleep: deps.sleep,
1013
2147
  waitStepMs: deps.waitStepMs,
1014
- });
1015
- await controller.reload();
2148
+ readinessTimeoutMs: deps.readinessTimeoutMs,
2149
+ };
1016
2150
  }
1017
2151
 
1018
2152
  export interface BotApi {
@@ -1327,6 +2461,7 @@ export interface TelegramDaemonOptions {
1327
2461
  pid?: number;
1328
2462
  /** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
1329
2463
  pidAlive?: (pid: number) => boolean;
2464
+ pidIncarnation?: (pid: number) => string | undefined;
1330
2465
  botApi?: BotApi;
1331
2466
  control?: DaemonControlHooks;
1332
2467
  /**
@@ -1350,6 +2485,8 @@ export interface TelegramDaemonOptions {
1350
2485
  rich?: { enabled: boolean };
1351
2486
  /** Opt-in rich-draft streaming of live turn previews (off by default; see rich-draft.ts). */
1352
2487
  richDraft?: { enabled: boolean };
2488
+ /** Tool start/completion messages (enabled by default). */
2489
+ toolActivity?: { enabled: boolean };
1353
2490
  /**
1354
2491
  * Per-session Telegram forum-topic naming. `nameTemplate` supports the
1355
2492
  * `{repo}`, `{branch}`, and `{title}` placeholders; unset preserves the
@@ -1405,9 +2542,21 @@ interface TopicAuthorityLease {
1405
2542
  authorityEpoch: number;
1406
2543
  }
1407
2544
 
2545
+ interface ToolActivityOwner {
2546
+ sessionId: string;
2547
+ toolCallId: string;
2548
+ toolName: string;
2549
+ endpointDigest: string;
2550
+ session: SessionSocket;
2551
+ phase: "started" | "terminal";
2552
+ policyEpoch?: number;
2553
+ }
2554
+
1408
2555
  interface PendingThreadedFrame {
1409
2556
  send: ThreadedSend;
1410
2557
  msg: Record<string, unknown>;
2558
+ endpointDigest: string;
2559
+ toolActivity?: ToolActivityOwner;
1411
2560
  }
1412
2561
 
1413
2562
  type SelectedAckOutcome =
@@ -1441,6 +2590,7 @@ interface TelegramQueuePayload {
1441
2590
  topicLease?: TopicAuthorityLease;
1442
2591
  selectedAck?: SelectedAckQueueItem;
1443
2592
  btwDelivery?: BtwQueuedDelivery;
2593
+ toolActivity?: ToolActivityOwner;
1444
2594
  }
1445
2595
 
1446
2596
  interface PendingBtwTurn {
@@ -1531,6 +2681,16 @@ export class TelegramNotificationDaemon {
1531
2681
  readonly messageRoutes = new Map<string | number, CallbackRoute | Omit<CallbackRoute, "answer">>();
1532
2682
  /** Telegram message id backing each streamed `${sessionId}:${coalesceKey}`, for in-place edits. */
1533
2683
  private readonly liveMessages = new Map<string, number>();
2684
+ /** Endpoint-bound ownership for visible or dispatching tool bubbles. */
2685
+ private readonly toolActivityOwners = new Map<string, ToolActivityOwner>();
2686
+ private readonly revokedToolEndpoints = new Set<string>();
2687
+ private readonly unresolvedToolTerminalizations = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
2688
+ private toolTerminalizationChain: Promise<void> = Promise.resolve();
2689
+ private toolActivityPolicyEpoch = 0;
2690
+ private toolActivityStopping = false;
2691
+ private readonly replayToolActivityEpochs = new WeakMap<Record<string, unknown>, number>();
2692
+ private toolShutdownBarrier: Promise<void> = Promise.resolve();
2693
+ private toolActivityAmbiguous = false;
1534
2694
  readonly sessions = new Map<string, SessionSocket>();
1535
2695
  /** Ephemeral aliases for model choices; deliberately never serialized across daemon restarts. */
1536
2696
  #modelChoiceAliases = new Map<string, ModelChoiceRoute>();
@@ -1640,7 +2800,13 @@ export class TelegramNotificationDaemon {
1640
2800
  * ~25s getUpdates timeout. Safe to call from a signal handler.
1641
2801
  */
1642
2802
  requestStop(_reason?: "reload" | "stop" | "signal"): void {
1643
- this.effects.beginShutdown();
2803
+ const toolShutdown = this.beginToolActivityShutdown();
2804
+ void toolShutdown
2805
+ .finally(() => {
2806
+ this.effects.beginShutdown();
2807
+ this.#deliveryAbort.abort();
2808
+ })
2809
+ .catch(() => undefined);
1644
2810
  for (const item of new Set(this.selectedAckPending.values())) {
1645
2811
  if (item.state === "queued") this.pool.removeById(item.itemId);
1646
2812
  else item.controller?.abort();
@@ -1651,7 +2817,6 @@ export class TelegramNotificationDaemon {
1651
2817
  delivery.invalidated = true;
1652
2818
  delivery.controller.abort("daemon_shutdown");
1653
2819
  }
1654
- this.#deliveryAbort.abort();
1655
2820
  this.runtime.requestStop();
1656
2821
  this.running = false;
1657
2822
  }
@@ -2458,7 +3623,131 @@ export class TelegramNotificationDaemon {
2458
3623
  * (so a delayed old close cannot delete a replacement), and best-effort closes
2459
3624
  * the socket. `scanRoots()` then reconnects the session.
2460
3625
  */
3626
+ private enqueueToolTerminalization(
3627
+ claimed:
3628
+ | Array<{ messageId: number; owner: ToolActivityOwner }>
3629
+ | (() => Array<{ messageId: number; owner: ToolActivityOwner }>),
3630
+ awaitDispatch: boolean,
3631
+ strict = false,
3632
+ ): Promise<void> {
3633
+ const terminalize = (): Promise<void> =>
3634
+ this.effects.allowTerminal(async () => {
3635
+ if (awaitDispatch) await this.flushChain;
3636
+ const strictFailures: Error[] = [];
3637
+ const claimedItems = typeof claimed === "function" ? claimed() : claimed;
3638
+ for (const item of claimedItems) {
3639
+ const { messageId, owner } = item;
3640
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3641
+ const send = renderThreadedFrame({
3642
+ type: "tool_activity",
3643
+ sessionId: owner.sessionId,
3644
+ toolCallId: owner.toolCallId,
3645
+ toolName: owner.toolName,
3646
+ phase: "unknown",
3647
+ });
3648
+ if (!send?.text) continue;
3649
+ let failure: unknown;
3650
+ let delivered = false;
3651
+ for (let attempt = 0; attempt < (strict ? 5 : 1); attempt++) {
3652
+ try {
3653
+ const response = (await this.botApi.call("editMessageText", {
3654
+ chat_id: this.opts.chatId,
3655
+ message_id: messageId,
3656
+ text: send.text,
3657
+ parse_mode: TELEGRAM_PARSE_MODE,
3658
+ })) as { ok?: boolean; description?: string } | undefined;
3659
+ delivered = response?.ok === true || /not modified/i.test(String(response?.description ?? ""));
3660
+ if (delivered) break;
3661
+ failure = new Error(String(response?.description ?? "Telegram rejected tool terminalization."));
3662
+ } catch (error) {
3663
+ failure = error;
3664
+ }
3665
+ if (strict && attempt < 4) await this.runtime.sleep(50 * 2 ** attempt);
3666
+ }
3667
+ if (delivered) {
3668
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3669
+ continue;
3670
+ }
3671
+ this.unresolvedToolTerminalizations.set(backlogKey, item);
3672
+ if (strict) {
3673
+ const key = `${owner.sessionId}:tool:${owner.toolCallId}`;
3674
+ if (!this.toolActivityOwners.has(key)) {
3675
+ this.toolActivityOwners.set(key, owner);
3676
+ this.liveMessages.set(key, messageId);
3677
+ }
3678
+ strictFailures.push(failure instanceof Error ? failure : new Error("Tool terminalization failed."));
3679
+ }
3680
+ }
3681
+ if (strictFailures.length > 0) {
3682
+ throw new AggregateError(strictFailures, strictFailures.map(failure => failure.message).join("; "));
3683
+ }
3684
+ });
3685
+ const next = this.toolTerminalizationChain.then(terminalize);
3686
+ this.toolTerminalizationChain = next.catch(() => {});
3687
+ return next;
3688
+ }
3689
+
3690
+ private scheduleVisibleToolTerminalization(endpointDigest?: string, strict = false): Promise<void> {
3691
+ if (endpointDigest !== undefined) this.revokedToolEndpoints.add(endpointDigest);
3692
+ const claimVisible = (): Array<{ messageId: number; owner: ToolActivityOwner }> => {
3693
+ const claimedByKey = new Map<string, { messageId: number; owner: ToolActivityOwner }>();
3694
+ for (const [backlogKey, item] of this.unresolvedToolTerminalizations) {
3695
+ if (endpointDigest !== undefined && item.owner.endpointDigest !== endpointDigest) continue;
3696
+ this.unresolvedToolTerminalizations.delete(backlogKey);
3697
+ claimedByKey.set(backlogKey, item);
3698
+ }
3699
+ for (const [key, owner] of this.toolActivityOwners) {
3700
+ if (endpointDigest !== undefined && owner.endpointDigest !== endpointDigest) continue;
3701
+ this.toolActivityOwners.delete(key);
3702
+ const messageId = this.liveMessages.get(key);
3703
+ this.liveMessages.delete(key);
3704
+ if (messageId !== undefined) {
3705
+ const backlogKey = `${owner.endpointDigest}\0${owner.sessionId}\0${owner.toolCallId}\0${messageId}`;
3706
+ claimedByKey.set(backlogKey, { messageId, owner });
3707
+ }
3708
+ }
3709
+ return [...claimedByKey.values()];
3710
+ };
3711
+ this.pool.removeWhere(
3712
+ item =>
3713
+ item.payload.toolActivity !== undefined &&
3714
+ (endpointDigest === undefined || item.payload.toolActivity.endpointDigest === endpointDigest),
3715
+ );
3716
+ const next = this.enqueueToolTerminalization(claimVisible, false, strict);
3717
+ if (endpointDigest !== undefined) {
3718
+ void next.then(
3719
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3720
+ () => this.revokedToolEndpoints.delete(endpointDigest),
3721
+ );
3722
+ }
3723
+ return next;
3724
+ }
3725
+
3726
+ private beginToolActivityShutdown(): Promise<void> {
3727
+ if (this.toolActivityStopping) return this.toolShutdownBarrier;
3728
+ this.toolActivityStopping = true;
3729
+ this.toolActivityPolicyEpoch++;
3730
+ this.toolShutdownBarrier = (async () => {
3731
+ await this.flushChain;
3732
+ const failures: Error[] = [];
3733
+ if (this.toolActivityAmbiguous) {
3734
+ failures.push(new Error("Tool activity delivery became ambiguous during daemon shutdown."));
3735
+ }
3736
+ try {
3737
+ await this.scheduleVisibleToolTerminalization(undefined, true);
3738
+ } catch (error) {
3739
+ failures.push(error instanceof Error ? error : new Error(String(error)));
3740
+ }
3741
+ if (failures.length > 0) {
3742
+ throw new AggregateError(failures, failures.map(failure => failure.message).join("; "));
3743
+ }
3744
+ })();
3745
+ return this.toolShutdownBarrier;
3746
+ }
3747
+
2461
3748
  private dropSession(session: SessionSocket, reason: string): void {
3749
+ const isCurrentSession = this.sessions.get(session.sessionId) === session;
3750
+ if (isCurrentSession) this.scheduleVisibleToolTerminalization(session.endpointDigest).catch(() => undefined);
2462
3751
  const clearIntervalImpl = this.opts.clearIntervalImpl ?? clearInterval;
2463
3752
  if (session.pingTimer) {
2464
3753
  clearIntervalImpl(session.pingTimer);
@@ -2469,7 +3758,6 @@ export class TelegramNotificationDaemon {
2469
3758
  } else {
2470
3759
  void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined);
2471
3760
  }
2472
- const isCurrentSession = this.sessions.get(session.sessionId) === session;
2473
3761
  if (isCurrentSession || reason === "session_closed") {
2474
3762
  this.deleteMessageRoutes(session.sessionId);
2475
3763
  }
@@ -2929,16 +4217,39 @@ export class TelegramNotificationDaemon {
2929
4217
  return !ownerId || ownerId === session.sessionId;
2930
4218
  }
2931
4219
 
4220
+ private toolActivityOwner(session: SessionSocket, msg: Record<string, unknown>): ToolActivityOwner | undefined {
4221
+ if (msg.type !== "tool_activity") return undefined;
4222
+ const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
4223
+ const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
4224
+ const phase = typeof msg.phase === "string" ? msg.phase : undefined;
4225
+ if (!toolCallId || !toolName || !phase) return undefined;
4226
+ return {
4227
+ sessionId: session.sessionId,
4228
+ toolCallId,
4229
+ toolName,
4230
+ endpointDigest: session.endpointDigest,
4231
+ session,
4232
+ phase: phase === "started" ? "started" : "terminal",
4233
+ };
4234
+ }
4235
+
4236
+ private toolActivityAuthorityIsCurrent(toolActivity: ToolActivityOwner): boolean {
4237
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest)) return false;
4238
+ const session = this.sessions.get(toolActivity.sessionId);
4239
+ if (toolActivity.endpointDigest === undefined) return session === undefined;
4240
+ return session === toolActivity.session && session.endpointDigest === toolActivity.endpointDigest;
4241
+ }
2932
4242
  private async submitThreadedFrame(
2933
4243
  sessionId: string,
2934
4244
  send: ThreadedSend,
2935
4245
  topicLease: TopicAuthorityLease,
4246
+ toolActivity?: ToolActivityOwner,
2936
4247
  ): Promise<void> {
2937
4248
  this.submitPool({
2938
4249
  sessionId,
2939
4250
  lane: send.lane,
2940
4251
  coalesceKey: send.coalesceKey,
2941
- payload: { send, topicLease },
4252
+ payload: { send, topicLease, ...(toolActivity ? { toolActivity } : {}) },
2942
4253
  });
2943
4254
  await this.flushPool();
2944
4255
  }
@@ -3000,18 +4311,33 @@ export class TelegramNotificationDaemon {
3000
4311
  }
3001
4312
  }
3002
4313
 
3003
- private rememberPendingThreadedFrame(sessionId: string, send: ThreadedSend, msg: Record<string, unknown>): void {
3004
- const frames = this.pendingThreadedFrames.get(sessionId) ?? [];
3005
- frames.push({ send, msg });
4314
+ private rememberPendingThreadedFrame(
4315
+ session: SessionSocket,
4316
+ send: ThreadedSend,
4317
+ msg: Record<string, unknown>,
4318
+ toolActivity?: ToolActivityOwner,
4319
+ ): void {
4320
+ const frames = this.pendingThreadedFrames.get(session.sessionId) ?? [];
4321
+ frames.push({
4322
+ send,
4323
+ msg,
4324
+ endpointDigest: session.endpointDigest,
4325
+ ...(toolActivity ? { toolActivity } : {}),
4326
+ });
3006
4327
  if (frames.length > PENDING_TOPIC_FRAME_LIMIT) frames.shift();
3007
- this.pendingThreadedFrames.set(sessionId, frames);
4328
+ this.pendingThreadedFrames.set(session.sessionId, frames);
3008
4329
  }
3009
4330
 
3010
4331
  private async flushPendingThreadedFrames(sessionId: string, topicLease: TopicAuthorityLease): Promise<void> {
3011
4332
  const frames = this.pendingThreadedFrames.get(sessionId);
3012
4333
  if (!frames || frames.length === 0) return;
3013
4334
  this.pendingThreadedFrames.delete(sessionId);
3014
- for (const frame of frames) await this.submitThreadedFrame(sessionId, frame.send, topicLease);
4335
+ const session = this.sessions.get(sessionId);
4336
+ for (const frame of frames) {
4337
+ if (session && session.endpointDigest !== frame.endpointDigest) continue;
4338
+ if (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled === false) continue;
4339
+ await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity);
4340
+ }
3015
4341
  }
3016
4342
 
3017
4343
  /**
@@ -3096,7 +4422,11 @@ export class TelegramNotificationDaemon {
3096
4422
  })) as { ok?: boolean };
3097
4423
  if (!topicDeleteSettled(res)) return;
3098
4424
  this.topics.settleDelete(sessionId, record.topicId);
3099
- for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) this.liveMessages.delete(k);
4425
+ for (const k of [...this.liveMessages.keys()])
4426
+ if (k.startsWith(`${sessionId}:`)) {
4427
+ this.liveMessages.delete(k);
4428
+ this.toolActivityOwners.delete(k);
4429
+ }
3100
4430
  this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
3101
4431
  if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
3102
4432
  });
@@ -3280,6 +4610,21 @@ export class TelegramNotificationDaemon {
3280
4610
  );
3281
4611
  }
3282
4612
  for (const item of batch) {
4613
+ const toolActivity = item.payload.toolActivity;
4614
+ if (
4615
+ toolActivity?.phase === "started" &&
4616
+ (this.toolActivityStopping ||
4617
+ this.opts.toolActivity?.enabled === false ||
4618
+ toolActivity.policyEpoch !== this.toolActivityPolicyEpoch ||
4619
+ !this.toolActivityAuthorityIsCurrent(toolActivity))
4620
+ ) {
4621
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
4622
+ const owner = this.toolActivityOwners.get(key);
4623
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
4624
+ this.toolActivityOwners.delete(key);
4625
+ this.pool.settle(item.itemId!, "removed");
4626
+ continue;
4627
+ }
3283
4628
  const btwDelivery = item.payload.btwDelivery;
3284
4629
  if (btwDelivery) {
3285
4630
  if (!btwDelivery.isAuthoritative()) {
@@ -3374,6 +4719,10 @@ export class TelegramNotificationDaemon {
3374
4719
  this.pool.settle(item.itemId!, "rejected");
3375
4720
  continue;
3376
4721
  }
4722
+ if (item.payload.toolActivity && !this.toolActivityAuthorityIsCurrent(item.payload.toolActivity)) {
4723
+ this.pool.settle(item.itemId!, "removed");
4724
+ continue;
4725
+ }
3377
4726
  // Threaded topic when available; otherwise deliver flat to the paired chat.
3378
4727
  const threadField = topicId ? { message_thread_id: Number(topicId) } : {};
3379
4728
  const ckey = send.editable ? item.coalesceKey : undefined;
@@ -3572,19 +4921,25 @@ export class TelegramNotificationDaemon {
3572
4921
  }
3573
4922
  }
3574
4923
  if (editKey && ckey !== undefined && firstMessageId !== undefined && !send.terminal) {
3575
- this.recordLiveMessage(item.sessionId, ckey, firstMessageId);
4924
+ this.recordLiveMessage(item.sessionId, ckey, firstMessageId, item.payload.toolActivity);
3576
4925
  }
3577
4926
  }
3578
4927
  }
3579
4928
  } catch {
3580
4929
  // Best-effort: a failed send/edit must never stop the daemon.
3581
4930
  disposition = "ambiguous";
4931
+ if (item.payload.toolActivity?.phase === "started") this.toolActivityAmbiguous = true;
3582
4932
  } finally {
3583
4933
  this.pool.settle(item.itemId!, disposition);
3584
4934
  // A terminal tool frame owns the end of this coalescing key even when both
3585
4935
  // edit and fallback delivery fail. Retaining the old message id would leak
3586
4936
  // one entry per failure and let a later reused key edit stale Telegram state.
3587
- if (send.terminal && editKey) this.liveMessages.delete(editKey);
4937
+ if (send.terminal && editKey) {
4938
+ this.liveMessages.delete(editKey);
4939
+ const owner = this.toolActivityOwners.get(editKey);
4940
+ if (!item.payload.toolActivity || owner?.session === item.payload.toolActivity.session)
4941
+ this.toolActivityOwners.delete(editKey);
4942
+ }
3588
4943
  }
3589
4944
  }
3590
4945
  }
@@ -3594,7 +4949,12 @@ export class TelegramNotificationDaemon {
3594
4949
  * so later live/finalized frames edit it in place. Evicts this session's stale
3595
4950
  * same-category entries (e.g. prior turns) so the map stays bounded.
3596
4951
  */
3597
- private recordLiveMessage(sessionId: string, coalesceKey: string, messageId: number): void {
4952
+ private recordLiveMessage(
4953
+ sessionId: string,
4954
+ coalesceKey: string,
4955
+ messageId: number,
4956
+ toolActivity?: ToolActivityOwner,
4957
+ ): void {
3598
4958
  const mapKey = `${sessionId}:${coalesceKey}`;
3599
4959
  const category = coalesceKey.split(":")[0] ?? "";
3600
4960
  // Single-slot categories (rolling turn/context/reasoning previews) evict prior
@@ -3609,7 +4969,15 @@ export class TelegramNotificationDaemon {
3609
4969
  if (k !== mapKey && k.startsWith(prefix)) this.liveMessages.delete(k);
3610
4970
  }
3611
4971
  }
4972
+ if (toolActivity) {
4973
+ const owner = this.toolActivityOwners.get(mapKey);
4974
+ if (this.revokedToolEndpoints.has(toolActivity.endpointDigest) || owner?.session !== toolActivity.session) {
4975
+ void this.enqueueToolTerminalization([{ messageId, owner: toolActivity }], false);
4976
+ return;
4977
+ }
4978
+ }
3612
4979
  this.liveMessages.set(mapKey, messageId);
4980
+ if (toolActivity) this.toolActivityOwners.set(mapKey, toolActivity);
3613
4981
  }
3614
4982
 
3615
4983
  /**
@@ -3621,11 +4989,20 @@ export class TelegramNotificationDaemon {
3621
4989
  * content never lands in a shared chat. Identity headers are sent at most once per
3622
4990
  * session in flat mode.
3623
4991
  */
3624
- private async deliverFlatFallback(sessionId: string, send: ThreadedSend): Promise<void> {
4992
+ private async deliverFlatFallback(
4993
+ sessionId: string,
4994
+ send: ThreadedSend,
4995
+ toolActivity?: ToolActivityOwner,
4996
+ ): Promise<void> {
3625
4997
  if (!(await this.pairedChatIsPrivate())) return;
3626
4998
  await this.notifyThreadedFallback();
3627
4999
  if (send.identity && this.flatIdentitySent.has(sessionId)) return;
3628
- this.submitPool({ sessionId, lane: send.lane, coalesceKey: send.coalesceKey, payload: { send } });
5000
+ this.submitPool({
5001
+ sessionId,
5002
+ lane: send.lane,
5003
+ coalesceKey: send.coalesceKey,
5004
+ payload: { send, ...(toolActivity ? { toolActivity } : {}) },
5005
+ });
3629
5006
  await this.flushPool();
3630
5007
  if (send.identity) this.flatIdentitySent.add(sessionId);
3631
5008
  }
@@ -3697,11 +5074,13 @@ export class TelegramNotificationDaemon {
3697
5074
  return renewDaemonHeartbeat({
3698
5075
  settings: this.opts.settings,
3699
5076
  ownerId: this.opts.ownerId,
5077
+ acquisitionId: this.opts.ownerId,
3700
5078
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
3701
5079
  chatId: this.opts.chatId,
3702
5080
  fs: this.fsImpl,
3703
5081
  now: this.opts.now,
3704
5082
  pid: this.opts.pid ?? process.pid,
5083
+ pidIncarnation: this.opts.pidIncarnation,
3705
5084
  });
3706
5085
  }
3707
5086
 
@@ -3848,9 +5227,13 @@ export class TelegramNotificationDaemon {
3848
5227
 
3849
5228
  async handleSessionMessage(session: SessionSocket, msg: any): Promise<void> {
3850
5229
  if (session.replayPending) {
5230
+ if (msg?.type === "tool_activity" && (this.opts.toolActivity?.enabled === false || this.toolActivityStopping))
5231
+ return;
3851
5232
  const matchingReplay = msg?.type === "event_replay_result" && msg.id === session.replayId;
3852
5233
  if (!matchingReplay) {
3853
- session.replayQueue.push(msg as Record<string, unknown>);
5234
+ const frame = msg as Record<string, unknown>;
5235
+ if (frame.type === "tool_activity") this.replayToolActivityEpochs.set(frame, this.toolActivityPolicyEpoch);
5236
+ session.replayQueue.push(frame);
3854
5237
  return;
3855
5238
  }
3856
5239
  session.replayPending = false;
@@ -3895,6 +5278,11 @@ export class TelegramNotificationDaemon {
3895
5278
  }
3896
5279
  const queued = session.replayQueue.splice(0);
3897
5280
  for (const frame of queued) {
5281
+ if (
5282
+ frame.type === "tool_activity" &&
5283
+ this.replayToolActivityEpochs.get(frame) !== this.toolActivityPolicyEpoch
5284
+ )
5285
+ continue;
3898
5286
  const fingerprint = JSON.stringify(frame);
3899
5287
  const remaining = replayCounts.get(fingerprint) ?? 0;
3900
5288
  if (remaining > 0) {
@@ -4129,12 +5517,63 @@ export class TelegramNotificationDaemon {
4129
5517
  return;
4130
5518
  }
4131
5519
  if (typeof msg?.type === "string" && TelegramNotificationDaemon.THREADED_FRAMES.has(msg.type)) {
5520
+ const threadedFrame = msg as Record<string, unknown>;
5521
+ const toolActivity = this.toolActivityOwner(session, threadedFrame);
5522
+ const toolAdmissionEpoch = toolActivity
5523
+ ? (this.replayToolActivityEpochs.get(threadedFrame) ?? this.toolActivityPolicyEpoch)
5524
+ : undefined;
5525
+ if (toolActivity) toolActivity.policyEpoch = toolAdmissionEpoch;
5526
+ const toolStartIsCurrent = (): boolean =>
5527
+ toolActivity?.phase !== "started" ||
5528
+ (this.toolActivityAuthorityIsCurrent(toolActivity) &&
5529
+ !this.toolActivityStopping &&
5530
+ this.opts.toolActivity?.enabled !== false &&
5531
+ toolAdmissionEpoch === this.toolActivityPolicyEpoch);
5532
+ const abandonStaleToolStart = (): void => {
5533
+ if (toolActivity?.phase !== "started") return;
5534
+ const key = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5535
+ const owner = this.toolActivityOwners.get(key);
5536
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
5537
+ this.toolActivityOwners.delete(key);
5538
+ };
5539
+ if (toolActivity) {
5540
+ const liveKey = `${session.sessionId}:tool:${toolActivity.toolCallId}`;
5541
+ const currentOwner = this.toolActivityOwners.get(liveKey);
5542
+ if (toolActivity.phase === "started") {
5543
+ if (!toolStartIsCurrent()) return;
5544
+ this.toolActivityOwners.set(liveKey, toolActivity);
5545
+ } else {
5546
+ if (currentOwner && currentOwner.session !== session) return;
5547
+ if (this.opts.toolActivity?.enabled === false) {
5548
+ if (!currentOwner) return;
5549
+ if (!this.liveMessages.has(liveKey)) {
5550
+ // A start may already be granted to the serialized dispatcher but not
5551
+ // yet recorded as visible. Wait for that exact dispatch boundary before
5552
+ // deciding whether its terminal frame must close a visible message.
5553
+ await this.flushChain;
5554
+ }
5555
+ const settledOwner = this.toolActivityOwners.get(liveKey);
5556
+ if (!this.liveMessages.has(liveKey) || settledOwner?.session !== session) {
5557
+ if (settledOwner?.session === session) this.toolActivityOwners.delete(liveKey);
5558
+ return;
5559
+ }
5560
+ }
5561
+ }
5562
+ }
4132
5563
  const send = renderThreadedFrame(msg);
4133
5564
  if (!send) return;
4134
5565
  const existingTopic = await this.existingTopicForPrivateChat(session.sessionId);
5566
+ if (!toolStartIsCurrent()) {
5567
+ abandonStaleToolStart();
5568
+ return;
5569
+ }
4135
5570
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4136
5571
  if (!send.identity && !existingTopic && !this.flatIdentitySent.has(session.sessionId)) {
4137
- this.rememberPendingThreadedFrame(session.sessionId, send, msg as Record<string, unknown>);
5572
+ if (!toolStartIsCurrent()) {
5573
+ abandonStaleToolStart();
5574
+ return;
5575
+ }
5576
+ this.rememberPendingThreadedFrame(session, send, threadedFrame, toolActivity);
4138
5577
  return;
4139
5578
  }
4140
5579
  if (send.identity && !this.sessionCanClaimIdentity(session, msg)) {
@@ -4148,9 +5587,13 @@ export class TelegramNotificationDaemon {
4148
5587
  const topicId =
4149
5588
  existingTopic ?? (await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, msg)));
4150
5589
  const topicLease = await this.topicAuthorityLease(session.sessionId);
5590
+ if (!toolStartIsCurrent()) {
5591
+ abandonStaleToolStart();
5592
+ return;
5593
+ }
4151
5594
  if (!topicId || !topicLease || topicLease.topicId !== topicId) {
4152
5595
  if (this.topics.get(session.sessionId)?.authorityState === "delete_pending") return;
4153
- await this.deliverFlatFallback(session.sessionId, send);
5596
+ await this.deliverFlatFallback(session.sessionId, send, toolActivity);
4154
5597
  return;
4155
5598
  }
4156
5599
  if (send.identity) {
@@ -4198,7 +5641,11 @@ export class TelegramNotificationDaemon {
4198
5641
  await this.persistTopics();
4199
5642
  return;
4200
5643
  }
4201
- await this.submitThreadedFrame(session.sessionId, send, topicLease);
5644
+ if (!toolStartIsCurrent()) {
5645
+ abandonStaleToolStart();
5646
+ return;
5647
+ }
5648
+ await this.submitThreadedFrame(session.sessionId, send, topicLease, toolActivity);
4202
5649
  return;
4203
5650
  }
4204
5651
  if (msg.type === "action_needed" && msg.id) {
@@ -4513,28 +5960,32 @@ export class TelegramNotificationDaemon {
4513
5960
  }
4514
5961
  }
4515
5962
  }
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.
5963
+ // Telegram delivery toggles are daemon-local policy, NOT session config
5964
+ // forwards. Handle them before threaded injection and independently of any
5965
+ // session WebSocket, so they work even when no session is connected.
4520
5966
  {
4521
5967
  const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
4522
5968
  const chat = m?.chat as { id?: unknown } | undefined;
4523
5969
  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.
5970
+ const commandToken = cmdText?.trim().split(/\s+/)[0]?.toLowerCase();
5971
+ const [command, commandSuffix] = commandToken?.split("@", 2) ?? [];
5972
+ const isRichCommand = command === "/rich";
5973
+ const isToolActivityCommand = command === "/toolactivity";
5974
+ if (
5975
+ isToolActivityCommand &&
5976
+ commandSuffix &&
5977
+ (!this.botUsername || commandSuffix !== this.botUsername.toLowerCase())
5978
+ )
5979
+ return;
5980
+ if (
5981
+ m !== undefined &&
5982
+ String(chat?.id) === String(this.opts.chatId) &&
5983
+ (isRichCommand || isToolActivityCommand)
5984
+ ) {
5985
+ // These commands mutate global config, so honor them ONLY in the
5986
+ // configured private chat. Fail closed for legacy or hand-edited group IDs.
4535
5987
  if (!(await this.pairedChatIsPrivate())) return;
4536
5988
  const updateId = (update as { update_id?: number }).update_id;
4537
- // Dedupe redelivered updates so a toggle+confirmation runs at most once.
4538
5989
  if (typeof updateId === "number") {
4539
5990
  if (this.dispatchState.seenUpdateIds.has(updateId)) return;
4540
5991
  await this.rememberSeenUpdateId(updateId);
@@ -4553,31 +6004,70 @@ export class TelegramNotificationDaemon {
4553
6004
  // Best-effort confirmation; never block on the notice.
4554
6005
  }
4555
6006
  };
4556
- const desired = parseRichToggleCommand(cmdText ?? "");
6007
+ const desired = isRichCommand
6008
+ ? parseRichToggleCommand(cmdText ?? "")
6009
+ : parseToolActivityToggleCommand(cmdText ?? "", this.botUsername);
6010
+ const usage = isRichCommand ? "Usage: /rich on|off" : "Usage: /toolactivity on|off";
4557
6011
  if (desired === undefined) {
4558
- await reply("Usage: /rich on|off");
6012
+ await reply(usage);
4559
6013
  return;
4560
6014
  }
6015
+ const settingPath = isRichCommand
6016
+ ? "notifications.telegram.rich.enabled"
6017
+ : "notifications.telegram.toolActivity.enabled";
6018
+ const label = isRichCommand ? "Rich messages" : "Tool activity";
4561
6019
  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);
6020
+ await this.opts.settings.set(settingPath, desired);
6021
+ // Confirm only after the global config write is durable.
6022
+ await flushTelegramToggleSettings(this.opts.settings);
4572
6023
  } catch (err) {
4573
6024
  logger.warn(
4574
- `notifications: /rich settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
6025
+ `notifications: ${command} settings write failed (${err instanceof Error ? err.message : String(err)}); runtime unchanged`,
4575
6026
  );
4576
- await reply("Rich messages: unchanged (settings write failed)");
6027
+ await reply(`${label}: unchanged (settings write failed)`);
4577
6028
  return;
4578
6029
  }
4579
- this.opts.rich = { enabled: desired };
4580
- await reply(desired ? "Rich messages: on" : "Rich messages: off");
6030
+ if (isRichCommand) {
6031
+ this.opts.rich = { enabled: desired };
6032
+ } else {
6033
+ this.toolActivityPolicyEpoch++;
6034
+ this.opts.toolActivity = { enabled: desired };
6035
+ if (!desired) {
6036
+ const removedTools = this.pool.removeWhere(
6037
+ item => item.lane === "live" && item.coalesceKey?.startsWith("tool:") === true,
6038
+ );
6039
+ for (const item of removedTools) {
6040
+ const toolActivity = item.payload.toolActivity;
6041
+ if (!toolActivity) continue;
6042
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6043
+ const owner = this.toolActivityOwners.get(key);
6044
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6045
+ this.toolActivityOwners.delete(key);
6046
+ }
6047
+ for (const [sessionId, frames] of this.pendingThreadedFrames) {
6048
+ for (const frame of frames) {
6049
+ const toolActivity = frame.toolActivity;
6050
+ if (!toolActivity || frame.msg.type !== "tool_activity") continue;
6051
+ const key = `${toolActivity.sessionId}:tool:${toolActivity.toolCallId}`;
6052
+ const owner = this.toolActivityOwners.get(key);
6053
+ if (!this.liveMessages.has(key) && owner?.session === toolActivity.session)
6054
+ this.toolActivityOwners.delete(key);
6055
+ }
6056
+ const retained = frames.filter(frame => frame.msg.type !== "tool_activity");
6057
+ if (retained.length === 0) this.pendingThreadedFrames.delete(sessionId);
6058
+ else this.pendingThreadedFrames.set(sessionId, retained);
6059
+ }
6060
+ for (const session of this.sessions.values()) {
6061
+ session.replayQueue = session.replayQueue.filter(frame => frame.type !== "tool_activity");
6062
+ }
6063
+ }
6064
+ // The policy flips before joining the serialized dispatch chain:
6065
+ // future starts are rejected immediately, queued starts are removed,
6066
+ // and any already-granted Bot API effect settles before the off
6067
+ // acknowledgement. Visible starts may still receive their terminal edit.
6068
+ await this.flushChain;
6069
+ }
6070
+ await reply(`${label}: ${desired ? "on" : "off"}`);
4581
6071
  return;
4582
6072
  }
4583
6073
  }
@@ -4895,6 +6385,10 @@ export class TelegramNotificationDaemon {
4895
6385
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
4896
6386
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
4897
6387
  { command: "rich", description: "Toggle rich Telegram delivery: /rich <on|off>" },
6388
+ {
6389
+ command: "toolactivity",
6390
+ description: "Toggle tool activity updates: /toolactivity <on|off>",
6391
+ },
4898
6392
  { command: "reasoning", description: "Show or change reasoning effort in this session" },
4899
6393
  { command: "usage", description: "Show provider/local usage for this session" },
4900
6394
  { command: "model", description: "Select a model for this session" },
@@ -4923,11 +6417,13 @@ export class TelegramNotificationDaemon {
4923
6417
  this.running = await renewDaemonHeartbeat({
4924
6418
  settings: this.opts.settings,
4925
6419
  ownerId: this.opts.ownerId,
6420
+ acquisitionId: this.opts.ownerId,
4926
6421
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
4927
6422
  chatId: this.opts.chatId,
4928
6423
  fs: this.fsImpl,
4929
6424
  now: this.opts.now,
4930
6425
  pid: this.opts.pid ?? process.pid,
6426
+ pidIncarnation: this.opts.pidIncarnation,
4931
6427
  });
4932
6428
  if (!this.running) return;
4933
6429
  this.runtime.start();
@@ -4949,7 +6445,20 @@ export class TelegramNotificationDaemon {
4949
6445
  let idleSince = this.runtime.now();
4950
6446
  while (this.running) {
4951
6447
  if (await this.controlStopRequested()) break;
4952
- if (!(await this.renewOwnershipHeartbeat())) break;
6448
+ if (
6449
+ !(await renewDaemonHeartbeat({
6450
+ settings: this.opts.settings,
6451
+ ownerId: this.opts.ownerId,
6452
+ acquisitionId: this.opts.ownerId,
6453
+ tokenFingerprint: tokenFingerprint(this.opts.botToken),
6454
+ chatId: this.opts.chatId,
6455
+ fs: this.fsImpl,
6456
+ now: this.opts.now,
6457
+ pid: this.opts.pid ?? process.pid,
6458
+ pidIncarnation: this.opts.pidIncarnation,
6459
+ }))
6460
+ )
6461
+ break;
4953
6462
  await this.runScan();
4954
6463
  if (await this.controlStopRequested()) break;
4955
6464
  const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
@@ -4984,10 +6493,19 @@ export class TelegramNotificationDaemon {
4984
6493
  await this.runtime.sleep(10);
4985
6494
  }
4986
6495
  } finally {
6496
+ let toolShutdownError: unknown;
6497
+ try {
6498
+ await this.beginToolActivityShutdown();
6499
+ } catch (error) {
6500
+ toolShutdownError = error;
6501
+ }
4987
6502
  this.effects.beginShutdown();
6503
+ this.#deliveryAbort.abort();
4988
6504
  let persisted = false;
4989
6505
  const shutdown = this.effects.allowTerminal(async () => {
6506
+ if (toolShutdownError) throw toolShutdownError;
4990
6507
  await this.#drainBtwTurns();
6508
+ await this.toolTerminalizationChain;
4991
6509
  this.runtime.stop();
4992
6510
  this.stopOwnershipHeartbeatTimer();
4993
6511
  this.stopFlushTimer();
@@ -5021,9 +6539,12 @@ export class TelegramNotificationDaemon {
5021
6539
  await releaseDaemonOwnership({
5022
6540
  settings: this.opts.settings,
5023
6541
  ownerId: this.opts.ownerId,
6542
+ acquisitionId: this.opts.ownerId,
5024
6543
  tokenFingerprint: tokenFingerprint(this.opts.botToken),
5025
6544
  chatId: this.opts.chatId,
5026
6545
  pid: this.opts.pid ?? process.pid,
6546
+ generation: DAEMON_GENERATION,
6547
+ pidIncarnation: this.opts.pidIncarnation,
5027
6548
  fs: this.fsImpl,
5028
6549
  now: this.opts.now,
5029
6550
  });