@gajae-code/coding-agent 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -37,7 +37,7 @@ import {
37
37
  type TelegramDaemonControlDeps,
38
38
  TelegramDaemonController,
39
39
  } from "./telegram-daemon-control";
40
- import { withTelegramSetupLease } from "./telegram-setup";
40
+ import { type TelegramSetupPreflight, withTelegramSetupLease } from "./telegram-setup";
41
41
 
42
42
  export { DAEMON_GENERATION, NOTIFICATION_PROTOCOL_VERSION } from "./telegram-daemon-contract";
43
43
 
@@ -143,7 +143,7 @@ export interface TelegramDaemonFs {
143
143
  open(path: string, flags: string, mode?: number): Promise<{ close(): Promise<void> }>;
144
144
  readdir(path: string): Promise<string[]>;
145
145
  chmod(path: string, mode: number): Promise<void>;
146
- stat?(path: string): Promise<{ mtimeMs: number }>;
146
+ stat?(path: string): Promise<{ mtimeMs: number; size?: number; dev?: number; ino?: number; ctimeMs?: number }>;
147
147
  readEndpointFile?(path: string): Promise<NotificationEndpointFile>;
148
148
  exactUnlink?(path: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
149
149
  }
@@ -231,6 +231,37 @@ const BOT_API_RETRY_ATTEMPTS = 3;
231
231
  // Backoff after a failed getUpdates long-poll so a persistent outage does not
232
232
  // busy-loop the daemon.
233
233
  const POLL_BACKOFF_MS = 1_000;
234
+ const AUTOMATIC_RELOAD_COOLDOWN_MS = 10 * 60 * 1_000;
235
+ // Default freshness-poll window a cooldown contender waits for a sibling's
236
+ // replacement daemon to publish a fresh ready owner before reloading itself.
237
+ const RELOAD_FRESHNESS_WAIT_MS = 15_000;
238
+ // Cooperative-then-forced termination + replacement-readiness bounds the reload
239
+ // controller can spend while holding the reservation lock (mirrors the control
240
+ // plane's graceful/kill defaults plus a readiness wait).
241
+ const RELOAD_CONTROLLER_GRACEFUL_MS = 8_000;
242
+ const RELOAD_CONTROLLER_KILL_MS = 3_000;
243
+ const RELOAD_RESERVATION_HEADROOM_MS = 10_000;
244
+
245
+ /**
246
+ * File-lock options whose acquisition budget covers the full reload-reservation
247
+ * critical section: the in-lock freshness poll plus the controller's
248
+ * graceful+kill+readiness sequence, with headroom. A contender must be able to
249
+ * wait out a legitimate slow reload and then attach, never fail startup.
250
+ */
251
+ export function reloadReservationLockOptions(input: {
252
+ freshnessWaitMs: number;
253
+ readinessTimeoutMs: number;
254
+ retryDelayMs?: number;
255
+ }): { staleMs: number; retries: number; retryDelayMs: number } {
256
+ const retryDelayMs = Math.max(input.retryDelayMs ?? 100, 1);
257
+ const criticalMs =
258
+ Math.max(input.freshnessWaitMs, 0) +
259
+ RELOAD_CONTROLLER_GRACEFUL_MS +
260
+ RELOAD_CONTROLLER_KILL_MS +
261
+ Math.max(input.readinessTimeoutMs, 0) +
262
+ RELOAD_RESERVATION_HEADROOM_MS;
263
+ return { staleMs: 10_000, retries: Math.max(1, Math.ceil(criticalMs / retryDelayMs)), retryDelayMs };
264
+ }
234
265
  // Telegram clears a chat action after ~5s; refresh slightly sooner to keep the
235
266
  // typing indicator alive while the agent is busy.
236
267
  const TYPING_REFRESH_INTERVAL_MS = 4_000;
@@ -507,9 +538,17 @@ type LegacyOwnershipLockMetadata = {
507
538
  incarnation?: string;
508
539
  startedAt: number;
509
540
  };
541
+ type V010OwnershipLockMetadata = {
542
+ size: 0;
543
+ mtimeMs?: number;
544
+ dev?: number;
545
+ ino?: number;
546
+ ctimeMs?: number;
547
+ };
510
548
  type OwnershipLockRead =
511
549
  | { kind: "missing" }
512
550
  | { kind: "malformed"; raw: string; mtimeMs?: number }
551
+ | { kind: "v010"; metadata: V010OwnershipLockMetadata }
513
552
  | { kind: "legacy"; metadata: LegacyOwnershipLockMetadata }
514
553
  | { kind: "valid"; metadata: OwnershipLockMetadata };
515
554
 
@@ -522,6 +561,20 @@ export async function readOwnershipLock(fsImpl: TelegramDaemonFs, file: string):
522
561
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
523
562
  throw error;
524
563
  }
564
+ if (raw.length === 0) {
565
+ const stat = await fsImpl.stat?.(file).catch(() => undefined);
566
+ if (stat?.size !== 0) return { kind: "malformed", raw, mtimeMs: stat?.mtimeMs };
567
+ return {
568
+ kind: "v010",
569
+ metadata: {
570
+ size: 0,
571
+ ...(stat?.mtimeMs === undefined ? {} : { mtimeMs: stat.mtimeMs }),
572
+ ...(stat?.dev === undefined ? {} : { dev: stat.dev }),
573
+ ...(stat?.ino === undefined ? {} : { ino: stat.ino }),
574
+ ...(stat?.ctimeMs === undefined ? {} : { ctimeMs: stat.ctimeMs }),
575
+ },
576
+ };
577
+ }
525
578
  try {
526
579
  const value = JSON.parse(raw) as Partial<OwnershipLockMetadata>;
527
580
  const pid = value.pid;
@@ -564,6 +617,7 @@ export function liveOwnershipLockDecision(input: {
564
617
  | { acquired: false; attached: false; blocked: true }
565
618
  | { acquired: false; attached: false; provisional: true }
566
619
  | undefined {
620
+ if (input.lock.kind === "v010") return { acquired: false, attached: false, blocked: true };
567
621
  if (input.lock.kind !== "valid" && input.lock.kind !== "legacy") return undefined;
568
622
  if (!input.pidAlive(input.lock.metadata.pid)) return undefined;
569
623
  if (input.lock.kind === "legacy") {
@@ -585,8 +639,9 @@ function ownershipLockMatches(left: OwnershipLockRead, right: OwnershipLockRead)
585
639
  if (left.kind === "missing") return true;
586
640
  if (left.kind === "malformed" && right.kind === "malformed")
587
641
  return left.raw === right.raw && left.mtimeMs !== undefined && left.mtimeMs === right.mtimeMs;
588
- if ((left.kind !== "valid" && left.kind !== "legacy") || (right.kind !== "valid" && right.kind !== "legacy"))
589
- return false;
642
+ if ((left.kind === "legacy" && right.kind === "legacy") || (left.kind === "v010" && right.kind === "v010"))
643
+ return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
644
+ if (left.kind !== "valid" || right.kind !== "valid") return false;
590
645
  return JSON.stringify(left.metadata) === JSON.stringify(right.metadata);
591
646
  }
592
647
 
@@ -601,10 +656,22 @@ function ownershipLockMatchesState(lock: OwnershipLockRead, state: DaemonState |
601
656
  );
602
657
  }
603
658
 
604
- function ownershipLockMatchesStoppedState(lock: OwnershipLockRead, state: unknown): boolean {
659
+ function ownershipLockMatchesStoppedState(
660
+ lock: OwnershipLockRead,
661
+ state: unknown,
662
+ pidAlive: (pid: number) => boolean,
663
+ ): boolean {
605
664
  if (isExplicitlyStoppedDaemonState(state)) return ownershipLockMatchesState(lock, state);
606
- if (!isLegacyStoppedDaemonState(state) || lock.kind !== "legacy") return false;
607
- const legacyState = state as Pick<DaemonState, "pid" | "startedAt"> & { stoppedAt: number };
665
+ if (!isLegacyStoppedDaemonState(state)) return false;
666
+ const legacyState = state as Pick<DaemonState, "pid" | "startedAt" | "generation"> & { stoppedAt: number };
667
+ if (lock.kind === "v010")
668
+ return (
669
+ legacyState.generation === 3 &&
670
+ !pidAlive(legacyState.pid) &&
671
+ lock.metadata.mtimeMs !== undefined &&
672
+ lock.metadata.mtimeMs <= legacyState.stoppedAt
673
+ );
674
+ if (lock.kind !== "legacy") return false;
608
675
  return (
609
676
  lock.metadata.pid === legacyState.pid &&
610
677
  lock.metadata.startedAt <= legacyState.startedAt &&
@@ -624,6 +691,37 @@ function ownershipLockMatchesMetadata(lock: OwnershipLockRead, metadata: Ownersh
624
691
  return lock.kind === "valid" && JSON.stringify(lock.metadata) === JSON.stringify(metadata);
625
692
  }
626
693
 
694
+ /**
695
+ * Accept an exact unlink only when it either completed or returned typed
696
+ * retained authority — a concrete detached quarantine plus a proven-absent
697
+ * canonical pathname. Anything else stays fail-closed.
698
+ */
699
+ async function exactUnlinkAcceptedWithRetainedEvidence(
700
+ fsImpl: TelegramDaemonFs,
701
+ file: string,
702
+ identity: NotificationEndpointFileIdentity,
703
+ ): Promise<boolean> {
704
+ const removed = await fsImpl.exactUnlink!(file, identity);
705
+ if (removed.ok) return true;
706
+ return (
707
+ removed.code === "cleanup_pending" &&
708
+ typeof removed.detachedPath === "string" &&
709
+ removed.detachedPath.length > 0 &&
710
+ (await fsImpl.readEndpointFile!(file).catch(() => undefined)) === undefined
711
+ );
712
+ }
713
+
714
+ async function unlinkOwnershipLockExactly(
715
+ fsImpl: TelegramDaemonFs,
716
+ file: string,
717
+ expected: OwnershipLockRead,
718
+ ): Promise<boolean> {
719
+ if (expected.kind === "missing" || !fsImpl.readEndpointFile || !fsImpl.exactUnlink) return false;
720
+ const endpoint = await fsImpl.readEndpointFile(file).catch(() => undefined);
721
+ if (!endpoint || !ownershipLockMatches(expected, await readOwnershipLock(fsImpl, file))) return false;
722
+ return await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, file, endpoint.identity);
723
+ }
724
+
627
725
  /**
628
726
  * Replace an acquisition's lease only after proving the old lease is unchanged.
629
727
  * The transition fence serializes lifecycle writers; the exact read-back prevents
@@ -687,6 +785,7 @@ async function ownershipLockIsReclaimable(input: {
687
785
  pidIncarnation: (pid: number) => string | undefined;
688
786
  }): Promise<boolean> {
689
787
  if (input.lock.kind === "missing") return true;
788
+ if (input.lock.kind === "v010") return false;
690
789
  if (input.lock.kind === "malformed") {
691
790
  if (!input.fs.stat) return false;
692
791
  const stat = await input.fs.stat(input.path).catch(() => undefined);
@@ -870,6 +969,172 @@ function notificationRootForCwd(cwd: string): string {
870
969
  return path.join(cwd, ".gjc", "state");
871
970
  }
872
971
 
972
+ /**
973
+ * Leak artifact prefixes left when native exact-unlink cleanup is retained
974
+ * (#2956). Nothing previously reaped these; they accumulate in the notifications
975
+ * directory across ownership transitions.
976
+ */
977
+ export const NOTIFICATION_LEAK_ARTIFACT_PREFIXES = [
978
+ ".gjc-delete-daemon-transition-",
979
+ ".gjc-exact-unlink-placeholder-",
980
+ ".gjc-delete-notification-endpoint-",
981
+ ] as const;
982
+
983
+ /** Grace window before a leak artifact is reaped (covers in-flight unlinks). */
984
+ export const NOTIFICATION_LEAK_ARTIFACT_GRACE_MS = 5 * 60_000;
985
+
986
+ /** True when a path is permanently gone (not a transient I/O blip). */
987
+ export function isPermanentMissingPathError(error: unknown): boolean {
988
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
989
+ return code === "ENOENT" || code === "ENOTDIR";
990
+ }
991
+
992
+ export function isNotificationLeakArtifactName(name: string): boolean {
993
+ return NOTIFICATION_LEAK_ARTIFACT_PREFIXES.some(prefix => name.startsWith(prefix));
994
+ }
995
+
996
+ type NotificationRootsRegistry = {
997
+ version?: number;
998
+ roots?: string[];
999
+ managedRoots?: string[];
1000
+ sessions?: Record<string, string>;
1001
+ };
1002
+
1003
+ /**
1004
+ * Drop permanently missing scan roots from the durable registry under the roots
1005
+ * lock. Session map entries that pointed only at those roots are removed too.
1006
+ */
1007
+ export async function pruneMissingNotificationRoots(input: {
1008
+ settings: Settings;
1009
+ fs?: TelegramDaemonFs;
1010
+ /** When set, only these roots are considered; otherwise every registered root is probed. */
1011
+ candidates?: readonly string[];
1012
+ }): Promise<{ pruned: string[]; remaining: number }> {
1013
+ const fsImpl = input.fs ?? nodeFs;
1014
+ const paths = daemonPaths(input.settings.getAgentDir());
1015
+ await ensureDir(fsImpl, paths.dir);
1016
+ const pruned: string[] = [];
1017
+ let remaining = 0;
1018
+ await withFileLock(
1019
+ paths.roots,
1020
+ async () => {
1021
+ const current = (await readJson<NotificationRootsRegistry>(fsImpl, paths.roots)) ?? {};
1022
+ const roots = [...(current.roots ?? [])];
1023
+ const managedRoots = new Set(current.managedRoots ?? []);
1024
+ const sessions = { ...(current.sessions ?? {}) };
1025
+ const candidateSet = input.candidates ? new Set(input.candidates) : undefined;
1026
+ const survivors: string[] = [];
1027
+ for (const root of roots) {
1028
+ if (candidateSet && !candidateSet.has(root)) {
1029
+ survivors.push(root);
1030
+ continue;
1031
+ }
1032
+ const sdkDir = path.join(root, "sdk");
1033
+ try {
1034
+ await fsImpl.readdir(sdkDir);
1035
+ survivors.push(root);
1036
+ } catch (error) {
1037
+ if (!isPermanentMissingPathError(error)) {
1038
+ // Transient unreadable: keep for the next pass.
1039
+ survivors.push(root);
1040
+ continue;
1041
+ }
1042
+ // Also accept a missing sdk dir when the root itself is still a directory
1043
+ // (empty registration) — still permanently useless for scans.
1044
+ try {
1045
+ await fsImpl.readdir(root);
1046
+ } catch (rootError) {
1047
+ if (!isPermanentMissingPathError(rootError)) {
1048
+ survivors.push(root);
1049
+ continue;
1050
+ }
1051
+ }
1052
+ pruned.push(root);
1053
+ managedRoots.delete(root);
1054
+ for (const [sessionId, mapped] of Object.entries(sessions)) {
1055
+ if (mapped === root) delete sessions[sessionId];
1056
+ }
1057
+ }
1058
+ }
1059
+ remaining = survivors.length;
1060
+ if (pruned.length === 0) return;
1061
+ await writeJsonAtomic(fsImpl, paths.roots, {
1062
+ version: 1,
1063
+ roots: Array.from(new Set(survivors)).sort(),
1064
+ managedRoots: Array.from(managedRoots).sort(),
1065
+ sessions,
1066
+ });
1067
+ },
1068
+ { staleMs: 10_000 },
1069
+ );
1070
+ return { pruned, remaining };
1071
+ }
1072
+
1073
+ /**
1074
+ * Reap retained exact-unlink / ownership-transition quarantine files older than
1075
+ * the grace window from the notifications directory.
1076
+ */
1077
+ export async function reapStaleNotificationArtifacts(input: {
1078
+ settings: Settings;
1079
+ fs?: TelegramDaemonFs;
1080
+ now?: () => number;
1081
+ graceMs?: number;
1082
+ }): Promise<{ removed: string[]; skipped: number }> {
1083
+ const fsImpl = input.fs ?? nodeFs;
1084
+ const paths = daemonPaths(input.settings.getAgentDir());
1085
+ await ensureDir(fsImpl, paths.dir);
1086
+ const now = input.now?.() ?? Date.now();
1087
+ const graceMs = input.graceMs ?? NOTIFICATION_LEAK_ARTIFACT_GRACE_MS;
1088
+ const removed: string[] = [];
1089
+ let skipped = 0;
1090
+ let names: string[];
1091
+ try {
1092
+ names = await fsImpl.readdir(paths.dir);
1093
+ } catch (error) {
1094
+ if (isPermanentMissingPathError(error)) return { removed, skipped };
1095
+ throw error;
1096
+ }
1097
+ for (const name of names) {
1098
+ if (!isNotificationLeakArtifactName(name)) continue;
1099
+ const file = path.join(paths.dir, name);
1100
+ try {
1101
+ const stat = fsImpl.stat ? await fsImpl.stat(file) : undefined;
1102
+ const age = stat ? now - stat.mtimeMs : Number.POSITIVE_INFINITY;
1103
+ if (Number.isFinite(age) && age < graceMs) {
1104
+ skipped += 1;
1105
+ continue;
1106
+ }
1107
+ await fsImpl.unlink(file);
1108
+ removed.push(file);
1109
+ } catch (error) {
1110
+ if (isPermanentMissingPathError(error)) continue;
1111
+ // Best-effort: a busy file must not fail daemon ownership.
1112
+ skipped += 1;
1113
+ }
1114
+ }
1115
+ return { removed, skipped };
1116
+ }
1117
+
1118
+ /**
1119
+ * Startup / ownership self-heal: prune dead roots and reap leak artifacts so
1120
+ * `gjc daemon reload` recovers a degraded install without manual surgery (#2956).
1121
+ */
1122
+ export async function healTelegramDaemonNotificationState(input: {
1123
+ settings: Settings;
1124
+ fs?: TelegramDaemonFs;
1125
+ now?: () => number;
1126
+ graceMs?: number;
1127
+ }): Promise<{ prunedRoots: string[]; removedArtifacts: string[] }> {
1128
+ const prune = await pruneMissingNotificationRoots(input);
1129
+ const reap = await reapStaleNotificationArtifacts(input);
1130
+ if (prune.pruned.length > 0 || reap.removed.length > 0) {
1131
+ logger.warn(
1132
+ `notifications: self-heal pruned ${prune.pruned.length} dead root(s), reaped ${reap.removed.length} leak artifact(s)`,
1133
+ );
1134
+ }
1135
+ return { prunedRoots: prune.pruned, removedArtifacts: reap.removed };
1136
+ }
1137
+
873
1138
  function validBotToken(token: unknown): token is string {
874
1139
  return typeof token === "string" && token.trim().length > 0;
875
1140
  }
@@ -959,24 +1224,98 @@ export function hasSafeDaemonStateShape(state: unknown): state is DaemonState {
959
1224
  );
960
1225
  }
961
1226
 
962
- type LegacyParentDaemonState = Omit<DaemonState, "incarnation" | "acquisitionId" | "ownershipPhase" | "generation"> & {
1227
+ const V010_PARENT_STATE_KEYS = [
1228
+ "pid",
1229
+ "ownerId",
1230
+ "tokenFingerprint",
1231
+ "chatId",
1232
+ "startedAt",
1233
+ "heartbeatAt",
1234
+ "roots",
1235
+ "version",
1236
+ ] as const;
1237
+ const V010_GENERATION_3_PARENT_STATE_KEYS = [...V010_PARENT_STATE_KEYS, "generation"] as const;
1238
+
1239
+ type ParentDaemonStateBase = Omit<
1240
+ DaemonState,
1241
+ "incarnation" | "acquisitionId" | "ownershipPhase" | "generation" | "launcherPid"
1242
+ > & {
963
1243
  incarnation?: undefined;
964
1244
  acquisitionId?: undefined;
965
1245
  ownershipPhase?: undefined;
1246
+ generation?: unknown;
1247
+ launcherPid?: undefined;
1248
+ };
1249
+ type GenerationAbsentParentDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
966
1250
  generation?: undefined;
967
1251
  };
1252
+ type Generation3ReleaseDaemonState = Omit<ParentDaemonStateBase, "generation"> & {
1253
+ generation: 3;
1254
+ };
1255
+ export type LegacyParentDaemonState = GenerationAbsentParentDaemonState | Generation3ReleaseDaemonState;
968
1256
 
969
1257
  interface LegacyMigrationAttestation {
1258
+ stateDigest: string;
1259
+ confirmed?: true;
1260
+ lock?: V010OwnershipLockMetadata;
970
1261
  pid: number;
971
1262
  incarnation: string;
972
1263
  heartbeatAt: number;
973
1264
  observedAt: number;
1265
+ tokenFingerprint: string;
1266
+ chatId: string;
1267
+ }
1268
+
1269
+ function historicalStateSerializer(state: LegacyParentDaemonState): string {
1270
+ const historicalState = {
1271
+ pid: state.pid,
1272
+ ownerId: state.ownerId,
1273
+ tokenFingerprint: state.tokenFingerprint,
1274
+ chatId: state.chatId,
1275
+ startedAt: state.startedAt,
1276
+ heartbeatAt: state.heartbeatAt,
1277
+ roots: state.roots,
1278
+ version: state.version,
1279
+ ...(state.generation === undefined ? {} : { generation: 3 }),
1280
+ };
1281
+ return `${JSON.stringify(historicalState, null, 2)}\n`;
1282
+ }
1283
+
1284
+ function legacyParentStateDigest(state: LegacyParentDaemonState): string {
1285
+ const { heartbeatAt: _heartbeatAt, ...immutableState } = state;
1286
+ return crypto
1287
+ .createHash("sha256")
1288
+ .update(historicalStateSerializer({ ...immutableState, heartbeatAt: 0 }))
1289
+ .digest("hex");
1290
+ }
1291
+
1292
+ function hasExactParentStateKeys(state: object, keys: readonly string[]): boolean {
1293
+ const actual = Object.keys(state);
1294
+ return actual.length === keys.length && actual.every((key, index) => key === keys[index]);
1295
+ }
1296
+
1297
+ function isGenerationAbsentParentDaemonState(state: unknown): state is GenerationAbsentParentDaemonState {
1298
+ return hasParentDaemonStateShape(state) && hasExactParentStateKeys(state, V010_PARENT_STATE_KEYS);
1299
+ }
1300
+
1301
+ function isGeneration3ReleaseDaemonState(state: unknown): state is Generation3ReleaseDaemonState {
1302
+ return (
1303
+ hasParentDaemonStateShape(state) &&
1304
+ state.generation === 3 &&
1305
+ hasExactParentStateKeys(state, V010_GENERATION_3_PARENT_STATE_KEYS)
1306
+ );
974
1307
  }
975
1308
 
976
- function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonState {
977
- const candidate = state as Partial<LegacyParentDaemonState> | undefined;
1309
+ function isParentDaemonState(state: unknown): state is LegacyParentDaemonState {
1310
+ return isGenerationAbsentParentDaemonState(state) || isGeneration3ReleaseDaemonState(state);
1311
+ }
1312
+
1313
+ function hasParentDaemonStateShape(state: unknown): state is ParentDaemonStateBase {
1314
+ const candidate = state as Partial<ParentDaemonStateBase> | undefined;
978
1315
  return Boolean(
979
1316
  candidate &&
1317
+ typeof candidate === "object" &&
1318
+ !Array.isArray(candidate) &&
980
1319
  Number.isSafeInteger(candidate.pid) &&
981
1320
  (candidate.pid ?? 0) > 0 &&
982
1321
  typeof candidate.ownerId === "string" &&
@@ -991,11 +1330,70 @@ function isLegacyParentDaemonState(state: unknown): state is LegacyParentDaemonS
991
1330
  candidate.incarnation === undefined &&
992
1331
  candidate.acquisitionId === undefined &&
993
1332
  candidate.ownershipPhase === undefined &&
994
- candidate.generation === undefined &&
995
- candidate.stoppedAt === undefined,
1333
+ candidate.launcherPid === undefined &&
1334
+ candidate.stoppedAt === undefined &&
1335
+ (candidate.generation === undefined || candidate.generation === 3),
996
1336
  );
997
1337
  }
998
1338
 
1339
+ const isLegacyParentDaemonState = isParentDaemonState;
1340
+
1341
+ /**
1342
+ * A live v0.10-shaped record is authority only in its exact historical form.
1343
+ * Parsed JSON alone cannot establish that form: field order and serialization
1344
+ * bytes are part of the migration attestation.
1345
+ */
1346
+ async function isLiveNoncanonicalParentState(input: {
1347
+ fs: TelegramDaemonFs;
1348
+ statePath: string;
1349
+ state: unknown;
1350
+ pidAlive: (pid: number) => boolean;
1351
+ pidIncarnation: (pid: number) => string | undefined;
1352
+ tokenFingerprint: string;
1353
+ chatId: string;
1354
+ }): Promise<boolean> {
1355
+ const candidate = input.state as Partial<ParentDaemonStateBase> | undefined;
1356
+ if (
1357
+ !candidate ||
1358
+ typeof candidate !== "object" ||
1359
+ Array.isArray(candidate) ||
1360
+ !validDaemonPid(candidate.pid) ||
1361
+ typeof candidate.ownerId !== "string" ||
1362
+ candidate.ownerId.length === 0 ||
1363
+ typeof candidate.tokenFingerprint !== "string" ||
1364
+ typeof candidate.chatId !== "string" ||
1365
+ !Number.isSafeInteger(candidate.startedAt) ||
1366
+ !Number.isSafeInteger(candidate.heartbeatAt) ||
1367
+ !Array.isArray(candidate.roots) ||
1368
+ !candidate.roots.every(root => typeof root === "string") ||
1369
+ candidate.version !== DAEMON_VERSION ||
1370
+ !input.pidAlive(candidate.pid)
1371
+ )
1372
+ return false;
1373
+ if (
1374
+ isStoppedDaemonState(candidate) ||
1375
+ (candidate.incarnation !== undefined &&
1376
+ (candidate.acquisitionId !== undefined || candidate.ownershipPhase !== undefined))
1377
+ )
1378
+ return false;
1379
+ if (
1380
+ candidate.incarnation !== undefined &&
1381
+ isProcessIncarnation(input.pidIncarnation(candidate.pid)) &&
1382
+ input.pidIncarnation(candidate.pid) !== candidate.incarnation &&
1383
+ !(candidate.tokenFingerprint === input.tokenFingerprint && candidate.chatId === input.chatId)
1384
+ )
1385
+ return false;
1386
+ if (!isParentDaemonState(candidate)) return true;
1387
+ try {
1388
+ return (await input.fs.readFile(input.statePath, "utf8")) !== historicalStateSerializer(candidate);
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ }
1393
+ function legacyOwnershipLockMatchesHandoffState(lock: OwnershipLockRead, state: unknown): boolean {
1394
+ return lock.kind === "v010" && isGeneration3ReleaseDaemonState(state);
1395
+ }
1396
+
999
1397
  function legacyMigrationAttestationPath(statePath: string): string {
1000
1398
  return `${statePath}.legacy-migration.json`;
1001
1399
  }
@@ -1003,6 +1401,7 @@ function legacyMigrationAttestationPath(statePath: string): string {
1003
1401
  async function legacyParentHandoffDecision(input: {
1004
1402
  fs: TelegramDaemonFs;
1005
1403
  statePath: string;
1404
+ lockPath: string;
1006
1405
  state: LegacyParentDaemonState;
1007
1406
  now: number;
1008
1407
  pidAlive: (pid: number) => boolean;
@@ -1010,7 +1409,15 @@ async function legacyParentHandoffDecision(input: {
1010
1409
  tokenFingerprint: string;
1011
1410
  chatId: string;
1012
1411
  }): Promise<
1013
- { acquired: false; attached: false; provisional?: boolean; reloadRequired?: boolean; blocked?: boolean } | undefined
1412
+ | {
1413
+ acquired: false;
1414
+ attached: false;
1415
+ provisional?: boolean;
1416
+ reloadRequired?: boolean;
1417
+ legacyReloadRequired?: boolean;
1418
+ blocked?: boolean;
1419
+ }
1420
+ | undefined
1014
1421
  > {
1015
1422
  const { state } = input;
1016
1423
  if (!input.pidAlive(state.pid)) return undefined;
@@ -1018,33 +1425,133 @@ async function legacyParentHandoffDecision(input: {
1018
1425
  return { acquired: false, attached: false, blocked: true };
1019
1426
  const incarnation = input.pidIncarnation(state.pid);
1020
1427
  if (!isProcessIncarnation(incarnation)) return { acquired: false, attached: false, blocked: true };
1021
- const attestationPath = legacyMigrationAttestationPath(input.statePath);
1022
- const previous = await readJson<LegacyMigrationAttestation>(input.fs, attestationPath);
1428
+ let stateBytes: string;
1429
+ try {
1430
+ stateBytes = await input.fs.readFile(input.statePath, "utf8");
1431
+ } catch {
1432
+ return { acquired: false, attached: false, provisional: true };
1433
+ }
1434
+ if (stateBytes !== historicalStateSerializer(state)) return { acquired: false, attached: false, blocked: true };
1435
+ const lock = await readOwnershipLock(input.fs, input.lockPath);
1436
+ const attestationLock =
1437
+ state.generation === undefined && lock.kind === "missing"
1438
+ ? undefined
1439
+ : lock.kind === "v010" && legacyOwnershipLockMatchesHandoffState(lock, state)
1440
+ ? lock.metadata
1441
+ : null;
1442
+ if (attestationLock === null) return { acquired: false, attached: false, blocked: true };
1443
+ const previous = await readJson<LegacyMigrationAttestation>(
1444
+ input.fs,
1445
+ legacyMigrationAttestationPath(input.statePath),
1446
+ );
1447
+ const stateDigest = legacyParentStateDigest(state);
1023
1448
  const attested = Boolean(
1024
1449
  previous &&
1025
- Number.isSafeInteger(previous.pid) &&
1026
- isProcessIncarnation(previous.incarnation) &&
1027
- Number.isSafeInteger(previous.heartbeatAt) &&
1028
- Number.isSafeInteger(previous.observedAt) &&
1450
+ previous.stateDigest === stateDigest &&
1451
+ JSON.stringify(previous.lock) === JSON.stringify(attestationLock) &&
1029
1452
  previous.pid === state.pid &&
1030
1453
  previous.incarnation === incarnation &&
1454
+ previous.tokenFingerprint === input.tokenFingerprint &&
1455
+ previous.chatId === input.chatId &&
1031
1456
  state.heartbeatAt > previous.heartbeatAt &&
1032
1457
  input.now - previous.observedAt <= HEARTBEAT_TTL_MS,
1033
1458
  );
1034
- await writeJsonAtomic(input.fs, attestationPath, {
1459
+ await writeJsonAtomic(input.fs, legacyMigrationAttestationPath(input.statePath), {
1460
+ stateDigest,
1461
+ ...(attested ? { confirmed: true as const } : {}),
1462
+ ...(attestationLock === undefined ? {} : { lock: attestationLock }),
1035
1463
  pid: state.pid,
1036
1464
  incarnation,
1037
1465
  heartbeatAt: state.heartbeatAt,
1038
1466
  observedAt: input.now,
1467
+ tokenFingerprint: input.tokenFingerprint,
1468
+ chatId: input.chatId,
1039
1469
  } satisfies LegacyMigrationAttestation);
1040
- return attested
1041
- ? { acquired: false, attached: false, reloadRequired: true }
1042
- : { acquired: false, attached: false, provisional: true };
1470
+ if (!attested) return { acquired: false, attached: false, provisional: true };
1471
+ return { acquired: false, attached: false, reloadRequired: true, legacyReloadRequired: true };
1472
+ }
1473
+
1474
+ export interface AttestedLegacyDaemonOwner {
1475
+ state: LegacyParentDaemonState;
1476
+ incarnation: string;
1477
+ }
1478
+
1479
+ /** Revalidate the exact two-observation legacy proof immediately before signaling. */
1480
+ export async function readAttestedLegacyDaemonOwner(input: {
1481
+ settings: Settings;
1482
+ fs?: TelegramDaemonFs;
1483
+ now?: () => number;
1484
+ pidIncarnation?: (pid: number) => string | undefined;
1485
+ tokenFingerprint: string;
1486
+ chatId: string;
1487
+ }): Promise<AttestedLegacyDaemonOwner | undefined> {
1488
+ const fsImpl = input.fs ?? nodeFs;
1489
+ const paths = daemonPaths(input.settings.getAgentDir());
1490
+ const state = await readJson<unknown>(fsImpl, paths.state);
1491
+ if (!isGeneration3ReleaseDaemonState(state)) return undefined;
1492
+ let stateBytes: string;
1493
+ try {
1494
+ stateBytes = await fsImpl.readFile(paths.state, "utf8");
1495
+ } catch {
1496
+ return undefined;
1497
+ }
1498
+ if (stateBytes !== historicalStateSerializer(state)) return undefined;
1499
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
1500
+ if (lock.kind !== "v010") return undefined;
1501
+ const attestation = await readJson<LegacyMigrationAttestation>(fsImpl, legacyMigrationAttestationPath(paths.state));
1502
+ const incarnation = (input.pidIncarnation ?? defaultPidIncarnation)(state.pid);
1503
+ const now = (input.now ?? Date.now)();
1504
+ if (
1505
+ attestation?.confirmed !== true ||
1506
+ !isProcessIncarnation(incarnation) ||
1507
+ attestation.stateDigest !== legacyParentStateDigest(state) ||
1508
+ JSON.stringify(attestation.lock) !== JSON.stringify(lock.metadata) ||
1509
+ attestation.pid !== state.pid ||
1510
+ attestation.incarnation !== incarnation ||
1511
+ attestation.tokenFingerprint !== input.tokenFingerprint ||
1512
+ attestation.chatId !== input.chatId ||
1513
+ attestation.heartbeatAt > state.heartbeatAt ||
1514
+ now < attestation.observedAt ||
1515
+ now - attestation.observedAt > HEARTBEAT_TTL_MS
1516
+ )
1517
+ return undefined;
1518
+ return { state, incarnation };
1043
1519
  }
1044
1520
 
1045
1521
  function isRecognizedLegacyGeneration(generation: number | undefined): boolean {
1046
- return (
1047
- generation === undefined || (Number.isSafeInteger(generation) && generation > 0 && generation < DAEMON_GENERATION)
1522
+ return generation === undefined || generation === 3;
1523
+ }
1524
+
1525
+ /**
1526
+ * A predecessor is reloadable only when it has the complete modern ownership
1527
+ * proof. Generation is absent from the first fully-provenanced modern records,
1528
+ * so it is an incompatible predecessor too; parent-format records remain
1529
+ * excluded because they lack this ownership proof.
1530
+ */
1531
+ function hasFullModernOwnerProvenance(
1532
+ state: DaemonState | undefined,
1533
+ pidIncarnation?: (pid: number) => string | undefined,
1534
+ ): boolean {
1535
+ return Boolean(
1536
+ hasSafeDaemonStateShape(state) &&
1537
+ state.generation !== 3 &&
1538
+ state.ownershipPhase === "ready" &&
1539
+ typeof state.acquisitionId === "string" &&
1540
+ state.acquisitionId.length > 0 &&
1541
+ ownerProvenanceMatches(state, pidIncarnation),
1542
+ );
1543
+ }
1544
+
1545
+ function isFullModernPredecessor(
1546
+ state: DaemonState | undefined,
1547
+ pidIncarnation?: (pid: number) => string | undefined,
1548
+ ): boolean {
1549
+ return Boolean(
1550
+ hasFullModernOwnerProvenance(state, pidIncarnation) &&
1551
+ (state?.generation === undefined ||
1552
+ (Number.isSafeInteger(state?.generation) &&
1553
+ (state?.generation as number) > 0 &&
1554
+ (state?.generation as number) < DAEMON_GENERATION)),
1048
1555
  );
1049
1556
  }
1050
1557
 
@@ -1086,18 +1593,23 @@ export function isPhysicalMatchingOwner(input: {
1086
1593
  pidAlive: (pid: number) => boolean;
1087
1594
  pidIncarnation?: (pid: number) => string | undefined;
1088
1595
  }): boolean {
1089
- const { state } = input;
1090
- return Boolean(
1091
- state &&
1092
- hasSafeDaemonStateShape(state) &&
1093
- state.stoppedAt === undefined &&
1094
- ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) &&
1095
- input.pidAlive(state.pid) &&
1096
- ownerProvenanceMatches(state, input.pidIncarnation),
1097
- );
1596
+ const persistedState: unknown = input.state;
1597
+ const legacyParentState = isLegacyParentDaemonState(persistedState) ? persistedState : undefined;
1598
+ const modernState = hasSafeDaemonStateShape(persistedState) ? persistedState : undefined;
1599
+ const state = modernState ?? legacyParentState;
1600
+ if (
1601
+ !state ||
1602
+ state.stoppedAt !== undefined ||
1603
+ !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId) ||
1604
+ !input.pidAlive(state.pid)
1605
+ )
1606
+ return false;
1607
+ return legacyParentState
1608
+ ? isProcessIncarnation((input.pidIncarnation ?? defaultPidIncarnation)(legacyParentState.pid))
1609
+ : modernState !== undefined && ownerProvenanceMatches(modernState, input.pidIncarnation);
1098
1610
  }
1099
1611
 
1100
- /** True only for a signalable legacy owner or this generation's ready bound child. */
1612
+ /** True only for a fully-provenanced modern owner outside the generation-3 parent schema. */
1101
1613
  export function isSignalableMatchingOwner(input: {
1102
1614
  state: DaemonState | undefined;
1103
1615
  tokenFingerprint: string;
@@ -1106,14 +1618,7 @@ export function isSignalableMatchingOwner(input: {
1106
1618
  pidIncarnation?: (pid: number) => string | undefined;
1107
1619
  }): boolean {
1108
1620
  const { state } = input;
1109
- return Boolean(
1110
- isPhysicalMatchingOwner(input) &&
1111
- (isRecognizedLegacyGeneration(state?.generation) ||
1112
- (state?.generation === DAEMON_GENERATION &&
1113
- state.ownershipPhase === "ready" &&
1114
- typeof state.acquisitionId === "string" &&
1115
- state.acquisitionId.length > 0)),
1116
- );
1621
+ return Boolean(isPhysicalMatchingOwner(input) && hasFullModernOwnerProvenance(state, input.pidIncarnation));
1117
1622
  }
1118
1623
 
1119
1624
  export function isFreshLiveOwner(input: {
@@ -1180,6 +1685,7 @@ export async function acquireDaemonOwnership(input: {
1180
1685
  provisional?: boolean;
1181
1686
  reason?: "identity_mismatch";
1182
1687
  reloadRequired?: boolean;
1688
+ legacyReloadRequired?: boolean;
1183
1689
  }> {
1184
1690
  const fsImpl = input.fs ?? nodeFs;
1185
1691
  const now = input.now ?? Date.now;
@@ -1216,6 +1722,7 @@ export async function acquireDaemonOwnership(input: {
1216
1722
  }
1217
1723
  if (!state || state.stoppedAt !== undefined || !ownerIdentityMatches(state, input.tokenFingerprint, input.chatId))
1218
1724
  return undefined;
1725
+ if (state.generation === 3) return { acquired: false, attached: false, blocked: true };
1219
1726
  // Unavailable provenance is ambiguous and must remain fail-closed. A
1220
1727
  // different authoritative incarnation proves PID reuse, so allow only the
1221
1728
  // transition-locked path below to reclaim the stale owner artifacts.
@@ -1238,19 +1745,18 @@ export async function acquireDaemonOwnership(input: {
1238
1745
  )
1239
1746
  return { acquired: false, attached: true };
1240
1747
  // A physical owner that cannot prove current compatibility is never safe to
1241
- // attach. Legacy, stale, and malformed generations require a handoff;
1242
- // otherwise retain the explicit provisional classification.
1748
+ // attach. A reload handoff additionally requires a fresh heartbeat: a stale
1749
+ // record must remain blocked rather than authorizing a signal to its PID.
1243
1750
  if (
1244
- state.version !== DAEMON_VERSION ||
1245
- !isFreshLiveOwner({
1751
+ isFreshLiveOwner({
1246
1752
  state,
1247
1753
  now: now(),
1248
1754
  tokenFingerprint: input.tokenFingerprint,
1249
1755
  chatId: input.chatId,
1250
1756
  pidAlive,
1251
1757
  pidIncarnation,
1252
- }) ||
1253
- isRecognizedLegacyGeneration(state.generation)
1758
+ }) &&
1759
+ (state.version !== DAEMON_VERSION || isFullModernPredecessor(state, pidIncarnation))
1254
1760
  )
1255
1761
  return { acquired: false, attached: false, reloadRequired: true };
1256
1762
  return { acquired: false, attached: false, provisional: true };
@@ -1278,12 +1784,48 @@ export async function acquireDaemonOwnership(input: {
1278
1784
  ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1279
1785
  : { acquired: false, attached: false, blocked: true };
1280
1786
  }
1787
+ if (
1788
+ await isLiveNoncanonicalParentState({
1789
+ fs: fsImpl,
1790
+ statePath: paths.state,
1791
+ state: existing,
1792
+ pidAlive,
1793
+ pidIncarnation,
1794
+ tokenFingerprint: input.tokenFingerprint,
1795
+ chatId: input.chatId,
1796
+ })
1797
+ )
1798
+ return { acquired: false, attached: false, blocked: true };
1281
1799
 
1282
1800
  const transition = await acquireTransitionLock({ fs: fsImpl, path: paths.steal, pidAlive, pidIncarnation });
1283
1801
  if (!transition) return { acquired: false, attached: false, provisional: true };
1284
1802
  try {
1285
1803
  const rechecked = await readJson<DaemonState>(fsImpl, paths.state);
1286
1804
  const recheckedLock = await readOwnershipLock(fsImpl, paths.lock);
1805
+ const recheckedForeignOwner = classifyForeignLiveOwner({
1806
+ state: rechecked,
1807
+ tokenFingerprint: input.tokenFingerprint,
1808
+ chatId: input.chatId,
1809
+ pidAlive,
1810
+ pidIncarnation,
1811
+ });
1812
+ if (recheckedForeignOwner) {
1813
+ return recheckedForeignOwner === "identity_mismatch"
1814
+ ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1815
+ : { acquired: false, attached: false, blocked: true };
1816
+ }
1817
+ if (
1818
+ await isLiveNoncanonicalParentState({
1819
+ fs: fsImpl,
1820
+ statePath: paths.state,
1821
+ state: rechecked,
1822
+ pidAlive,
1823
+ pidIncarnation,
1824
+ tokenFingerprint: input.tokenFingerprint,
1825
+ chatId: input.chatId,
1826
+ })
1827
+ )
1828
+ return { acquired: false, attached: false, blocked: true };
1287
1829
  const recheckedDecision = isLegacyParentDaemonState(rechecked) ? undefined : attachDecision(rechecked);
1288
1830
  if (
1289
1831
  recheckedDecision &&
@@ -1297,13 +1839,19 @@ export async function acquireDaemonOwnership(input: {
1297
1839
  // A newer initializer may have replaced the pathname while that tombstone
1298
1840
  // remained, and must receive the same liveness/freshness protection as any
1299
1841
  // other live reservation.
1300
- const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked);
1301
- const lockDecision = stoppedLockMatches
1302
- ? undefined
1303
- : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1842
+ const stoppedLockMatches = ownershipLockMatchesStoppedState(recheckedLock, rechecked, pidAlive);
1843
+ // A v0.10 parent owns a legacy { pid, startedAt } lock. An exact
1844
+ // state/lock pair is authority only to attest or retry handoff of that
1845
+ // owner; it is never attached or unlinked here.
1846
+ const legacyHandoffLockMatches = legacyOwnershipLockMatchesHandoffState(recheckedLock, rechecked);
1847
+ const lockDecision =
1848
+ stoppedLockMatches || legacyHandoffLockMatches
1849
+ ? undefined
1850
+ : liveOwnershipLockDecision({ lock: recheckedLock, pidAlive, pidIncarnation });
1304
1851
  if (lockDecision) return lockDecision;
1305
1852
  if (
1306
1853
  !stoppedLockMatches &&
1854
+ !legacyHandoffLockMatches &&
1307
1855
  !(await ownershipLockIsReclaimable({
1308
1856
  fs: fsImpl,
1309
1857
  path: paths.lock,
@@ -1318,6 +1866,7 @@ export async function acquireDaemonOwnership(input: {
1318
1866
  const legacyDecision = await legacyParentHandoffDecision({
1319
1867
  fs: fsImpl,
1320
1868
  statePath: paths.state,
1869
+ lockPath: paths.lock,
1321
1870
  state: rechecked,
1322
1871
  now: now(),
1323
1872
  pidAlive,
@@ -1329,18 +1878,6 @@ export async function acquireDaemonOwnership(input: {
1329
1878
  } else if (recheckedDecision) {
1330
1879
  return recheckedDecision;
1331
1880
  }
1332
- const recheckedForeignOwner = classifyForeignLiveOwner({
1333
- state: rechecked,
1334
- tokenFingerprint: input.tokenFingerprint,
1335
- chatId: input.chatId,
1336
- pidAlive,
1337
- pidIncarnation,
1338
- });
1339
- if (recheckedForeignOwner) {
1340
- return recheckedForeignOwner === "identity_mismatch"
1341
- ? { acquired: false, attached: false, blocked: true, reason: "identity_mismatch" }
1342
- : { acquired: false, attached: false, blocked: true };
1343
- }
1344
1881
  if (
1345
1882
  hasSafeDaemonStateShape(rechecked) &&
1346
1883
  rechecked.stoppedAt === undefined &&
@@ -1354,7 +1891,8 @@ export async function acquireDaemonOwnership(input: {
1354
1891
  if (currentLock.kind !== "missing") {
1355
1892
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
1356
1893
  return { acquired: false, attached: false, provisional: true };
1357
- await fsImpl.unlink(paths.lock);
1894
+ if (!(await unlinkOwnershipLockExactly(fsImpl, paths.lock, currentLock)))
1895
+ return { acquired: false, attached: false, provisional: true };
1358
1896
  }
1359
1897
  const ownershipLock: OwnershipLockMetadata = {
1360
1898
  pid,
@@ -1590,14 +2128,15 @@ export async function retireProvisionalDaemonOwnership(input: {
1590
2128
  )
1591
2129
  return false;
1592
2130
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2131
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2132
+ if (!ownershipLockMatchesState(lock, state)) return false;
1593
2133
  await writeJsonAtomic(fsImpl, paths.state, {
1594
2134
  ...state,
1595
2135
  ownershipPhase: "retired",
1596
2136
  stoppedAt: (input.now ?? Date.now)(),
1597
2137
  });
1598
- if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1599
- await fsImpl.unlink(paths.lock).catch(() => undefined);
1600
- return true;
2138
+ if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2139
+ return await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
1601
2140
  } finally {
1602
2141
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1603
2142
  }
@@ -1754,6 +2293,8 @@ export async function confirmTelegramDaemonSpawn(input: {
1754
2293
  sleep?: (ms: number) => Promise<void>;
1755
2294
  waitStepMs?: number;
1756
2295
  timeoutMs?: number;
2296
+ /** Retain an unproven no-PID child lease during a successor handoff. */
2297
+ preserveOnUnprovenChildExit?: boolean;
1757
2298
  }): Promise<boolean> {
1758
2299
  if (input.spawned.result !== "owner_spawned") return true;
1759
2300
  const childPid = input.spawned.acquisition.pid;
@@ -1775,6 +2316,14 @@ export async function confirmTelegramDaemonSpawn(input: {
1775
2316
  timeoutMs: input.timeoutMs,
1776
2317
  });
1777
2318
  if (ready) return true;
2319
+ // An identified live child remains fenced. A no-PID launch retains its lease
2320
+ // only when a successor handoff explicitly requests that protection; ordinary
2321
+ // confirmation keeps the historical cleanup behavior.
2322
+ if (
2323
+ (hasExactChildPid && (input.pidAlive ?? defaultPidAlive)(childPid as number)) ||
2324
+ (!hasExactChildPid && input.preserveOnUnprovenChildExit)
2325
+ )
2326
+ return false;
1778
2327
  const launcherPid = input.spawned.acquisition.launcherPid ?? input.pid;
1779
2328
  const retired = await retireProvisionalDaemonOwnership({
1780
2329
  settings: input.settings,
@@ -1856,9 +2405,11 @@ export async function releaseDaemonOwnership(input: {
1856
2405
  )
1857
2406
  return;
1858
2407
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))) return;
2408
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2409
+ if (!ownershipLockMatchesState(lock, state)) return;
1859
2410
  await writeJsonAtomic(fsImpl, paths.state, { ...state, stoppedAt: (input.now ?? Date.now)() });
1860
2411
  if (await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
1861
- await fsImpl.unlink(paths.lock).catch(() => undefined);
2412
+ await unlinkOwnershipLockExactly(fsImpl, paths.lock, lock);
1862
2413
  } finally {
1863
2414
  await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
1864
2415
  }
@@ -1881,6 +2432,57 @@ export async function readDaemonRoots(
1881
2432
  return roots?.roots ?? [];
1882
2433
  }
1883
2434
 
2435
+ /** Injectable readers for {@link resolveTelegramSetupPreflight}, defaulting to the real OS/state probes. */
2436
+ export interface ResolveTelegramSetupPreflightDeps {
2437
+ readDaemonState?: (settings: Settings) => Promise<DaemonState | undefined>;
2438
+ pidAlive?: (pid: number) => boolean;
2439
+ pidIncarnation?: (pid: number) => string | undefined;
2440
+ }
2441
+
2442
+ /**
2443
+ * Build the Telegram setup preflight from persisted daemon state. The daemon is
2444
+ * reported live ONLY when its PID is alive AND its current process incarnation
2445
+ * still matches the persisted incarnation. Skipping the incarnation check makes
2446
+ * a stale state file whose PID has been recycled by an unrelated process
2447
+ * masquerade as a live owner, which wrongly blocks discovery pairing. Both the
2448
+ * `notify setup` CLI and the /settings Notifications tab share this resolver so
2449
+ * pairing behaves identically on both surfaces.
2450
+ */
2451
+ export async function resolveTelegramSetupPreflight(
2452
+ settings: Settings,
2453
+ deps: ResolveTelegramSetupPreflightDeps = {},
2454
+ ): Promise<TelegramSetupPreflight> {
2455
+ const storedChatId = getNotificationConfig(settings).chatId;
2456
+ const pidAlive = deps.pidAlive ?? defaultPidAlive;
2457
+ const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
2458
+ try {
2459
+ const state = await (deps.readDaemonState ?? readDaemonState)(settings);
2460
+ if (!state) return { storedChatId };
2461
+ const validPid = Number.isSafeInteger(state.pid) && state.pid > 0;
2462
+ if (!validPid || !pidAlive(state.pid)) return { storedChatId };
2463
+ const persistedIncarnation = state.incarnation;
2464
+ const currentIncarnation = pidIncarnation(state.pid);
2465
+ if (
2466
+ !isProcessIncarnation(persistedIncarnation) ||
2467
+ !isProcessIncarnation(currentIncarnation) ||
2468
+ persistedIncarnation !== currentIncarnation
2469
+ )
2470
+ return { storedChatId };
2471
+ return {
2472
+ storedChatId,
2473
+ daemon: {
2474
+ live: true,
2475
+ tokenFingerprint: typeof state.tokenFingerprint === "string" ? state.tokenFingerprint : undefined,
2476
+ chatId: typeof state.chatId === "string" ? state.chatId : undefined,
2477
+ },
2478
+ };
2479
+ } catch {
2480
+ // A state read failure is not proof of a live daemon; proceed normally. The
2481
+ // daemon's own 409 handling remains the backstop against poller contention.
2482
+ return { storedChatId };
2483
+ }
2484
+ }
2485
+
1884
2486
  function defaultPidAlive(pid: number): boolean {
1885
2487
  try {
1886
2488
  process.kill(pid, 0);
@@ -1945,7 +2547,13 @@ export interface TelegramSpawnAcquisition {
1945
2547
 
1946
2548
  export type TelegramSpawnOwnerResult =
1947
2549
  | { result: "owner_spawned"; acquisition: TelegramSpawnAcquisition; runtime: DaemonRuntimeInfo; warnings: string[] }
1948
- | { result: "attached"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean }
2550
+ | {
2551
+ result: "attached";
2552
+ runtime: DaemonRuntimeInfo;
2553
+ warnings: string[];
2554
+ reloadRequired?: boolean;
2555
+ legacyReloadRequired?: boolean;
2556
+ }
1949
2557
  | { result: "blocked"; runtime: DaemonRuntimeInfo; warnings: string[]; reloadRequired?: boolean };
1950
2558
 
1951
2559
  /**
@@ -2028,6 +2636,7 @@ export async function spawnTelegramDaemonOwner(
2028
2636
  runtime: buildTelegramDaemonSpawnArgs({ execPath, ownerId: "", agentDir }).runtime,
2029
2637
  warnings: [],
2030
2638
  reloadRequired: ownership.reloadRequired,
2639
+ legacyReloadRequired: ownership.legacyReloadRequired,
2031
2640
  };
2032
2641
  }
2033
2642
  const launcherPid = deps.pid ?? process.pid;
@@ -2180,7 +2789,7 @@ export async function reclaimDeadDaemonOwner(input: {
2180
2789
  if (!(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition })))
2181
2790
  return { recovered: false, reason: "transition-contended" };
2182
2791
  for (const endpoint of endpoints)
2183
- if (!(await fsImpl.exactUnlink(endpoint.file, endpoint.identity)).ok)
2792
+ if (!(await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, endpoint.file, endpoint.identity)))
2184
2793
  return { recovered: false, reason: "endpoint-changed" };
2185
2794
  if (currentLock.kind === "missing") return { recovered: true, reason: "cleared" };
2186
2795
  const exactLock = await fsImpl.readEndpointFile!(paths.lock).catch(() => undefined);
@@ -2191,7 +2800,7 @@ export async function reclaimDeadDaemonOwner(input: {
2191
2800
  !(await transitionLockIsHeldByCaller({ fs: fsImpl, path: paths.steal, lock: transition }))
2192
2801
  )
2193
2802
  return { recovered: false, reason: "lock-changed" };
2194
- return (await fsImpl.exactUnlink(paths.lock, exactLock.identity)).ok
2803
+ return (await exactUnlinkAcceptedWithRetainedEvidence(fsImpl, paths.lock, exactLock.identity))
2195
2804
  ? { recovered: true, reason: "cleared" }
2196
2805
  : { recovered: false, reason: "lock-changed" };
2197
2806
  } finally {
@@ -2211,6 +2820,13 @@ export async function ensureTelegramDaemonRunningDetailed(
2211
2820
  if (!isTelegramConfigured(cfg)) return "disabled";
2212
2821
  const root = notificationRootForCwd(input.cwd);
2213
2822
  const fp = tokenFingerprint(cfg.botToken);
2823
+ // A live v0.10 parent has no stable process authority on Windows. Never turn an
2824
+ // unproven cooperative handoff into destructive cleanup or a replacement spawn.
2825
+ if ((deps.platform ?? process.platform) === "win32") {
2826
+ const parentState: unknown = await readDaemonState(input.settings, deps.fs);
2827
+ if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid))
2828
+ return "blocked_identity";
2829
+ }
2214
2830
  // Windows can retain dead launcher metadata without an ownership lock; reclaim
2215
2831
  // its dead discovery records before the replacement can publish a new owner.
2216
2832
  if ((deps.platform ?? process.platform) === "win32" && !deps.fs) {
@@ -2259,7 +2875,11 @@ export async function ensureTelegramDaemonRunningDetailed(
2259
2875
  waitStepMs: deps.waitStepMs,
2260
2876
  timeoutMs: deps.readinessTimeoutMs,
2261
2877
  });
2262
- if (ready) {
2878
+ // A legacy parent cannot satisfy the current ready-state shape, but the
2879
+ // bounded wait gives its heartbeat a second observation window. Retry the
2880
+ // acquisition once so legacyParentHandoffDecision can consume that
2881
+ // attestation before startup treats the owner as blocked.
2882
+ if (ready || isLegacyParentDaemonState(provisional)) {
2263
2883
  spawned = await withTelegramSetupLease(
2264
2884
  cfg.botToken,
2265
2885
  async () =>
@@ -2300,9 +2920,78 @@ export async function ensureTelegramDaemonRunningDetailed(
2300
2920
  if (spawned.result === "attached" && spawned.reloadRequired) {
2301
2921
  const previous = await readNotificationRootRegistration({ ...input, fs: deps.fs });
2302
2922
  await registerNotificationRoot({ ...input, fs: deps.fs });
2303
- const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2304
- const upgrade = await controller.reloadForGenerationUpgrade();
2305
- if (upgrade.outcome !== "ready") {
2923
+ const fsImpl = deps.fs ?? nodeFs;
2924
+ const now = deps.now ?? Date.now;
2925
+ const pidAlive = deps.pidAlive ?? defaultPidAlive;
2926
+ const pidIncarnation = deps.pidIncarnation ?? defaultPidIncarnation;
2927
+ const reloadAttemptPath = path.join(
2928
+ daemonPaths(input.settings.getAgentDir()).dir,
2929
+ "telegram-daemon.reload-attempt.json",
2930
+ );
2931
+ await ensureDir(fsImpl, daemonPaths(input.settings.getAgentDir()).dir);
2932
+ const reloadWaitStepMs = Math.max(deps.waitStepMs ?? 100, 1);
2933
+ const reloadFreshnessWaitMs = Math.max(deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS, reloadWaitStepMs);
2934
+ const reloadReadinessTimeoutMs = deps.readinessTimeoutMs ?? RELOAD_FRESHNESS_WAIT_MS;
2935
+ const reloadResult = await withFileLock(
2936
+ reloadAttemptPath,
2937
+ async () => {
2938
+ const currentState = await readDaemonState(input.settings, fsImpl);
2939
+ const previousAttempt = await readJson<{
2940
+ lastReloadAt?: number;
2941
+ ownerId?: string;
2942
+ targetGeneration?: number;
2943
+ }>(fsImpl, reloadAttemptPath).catch(() => undefined);
2944
+ const reloadNow = now();
2945
+ const cooldownApplies =
2946
+ previousAttempt?.targetGeneration === DAEMON_GENERATION &&
2947
+ typeof previousAttempt.lastReloadAt === "number" &&
2948
+ Number.isFinite(previousAttempt.lastReloadAt) &&
2949
+ reloadNow - previousAttempt.lastReloadAt < AUTOMATIC_RELOAD_COOLDOWN_MS;
2950
+ if (cooldownApplies) {
2951
+ // A sibling ensure may have just finished its reload. Its replacement
2952
+ // daemon heartbeats only after startup, so wait briefly for the fresh
2953
+ // ready owner instead of issuing a duplicate reload in the gap.
2954
+ const sleep = deps.sleep ?? Bun.sleep;
2955
+ const waitStepMs = reloadWaitStepMs;
2956
+ const waitBudgetMs = reloadFreshnessWaitMs;
2957
+ const deadline = reloadNow + waitBudgetMs;
2958
+ // Iteration-capped so a frozen `now` cannot spin forever.
2959
+ const maxWaits = Math.ceil(waitBudgetMs / waitStepMs);
2960
+ for (let waits = 0; waits <= maxWaits; waits++) {
2961
+ const state = await readDaemonState(input.settings, fsImpl);
2962
+ const t = now();
2963
+ if (
2964
+ isFreshLiveOwner({
2965
+ state,
2966
+ now: t,
2967
+ tokenFingerprint: fp,
2968
+ chatId: cfg.chatId,
2969
+ pidAlive,
2970
+ pidIncarnation,
2971
+ })
2972
+ )
2973
+ return "attached" as const;
2974
+ if (t >= deadline) break;
2975
+ await sleep(waitStepMs);
2976
+ }
2977
+ }
2978
+ await writeJsonAtomic(fsImpl, reloadAttemptPath, {
2979
+ lastReloadAt: reloadNow,
2980
+ ownerId: currentState?.ownerId ?? "",
2981
+ targetGeneration: DAEMON_GENERATION,
2982
+ });
2983
+ const controller = new TelegramDaemonController(input.settings, telegramControllerDeps(deps));
2984
+ const upgrade = await controller.reloadForGenerationUpgrade({}, spawned.legacyReloadRequired === true);
2985
+ return upgrade.outcome === "ready" ? ("reloaded" as const) : upgrade;
2986
+ },
2987
+ reloadReservationLockOptions({
2988
+ freshnessWaitMs: reloadFreshnessWaitMs,
2989
+ readinessTimeoutMs: reloadReadinessTimeoutMs,
2990
+ retryDelayMs: reloadWaitStepMs,
2991
+ }),
2992
+ );
2993
+ if (reloadResult === "attached") return "attached";
2994
+ if (reloadResult !== "reloaded") {
2306
2995
  await restoreNotificationRootRegistration({
2307
2996
  settings: input.settings,
2308
2997
  sessionId: input.sessionId,
@@ -2310,7 +2999,7 @@ export async function ensureTelegramDaemonRunningDetailed(
2310
2999
  previous,
2311
3000
  fs: deps.fs,
2312
3001
  });
2313
- throw new Error(`Unable to replace stale Telegram daemon: ${upgrade.operation.message}`);
3002
+ throw new Error(`Unable to replace stale Telegram daemon: ${reloadResult.operation.message}`);
2314
3003
  }
2315
3004
  return "reloaded";
2316
3005
  }
@@ -2363,12 +3052,28 @@ export async function ensureTelegramDaemonRunning(
2363
3052
  }
2364
3053
 
2365
3054
  function telegramControllerDeps(deps: TelegramDaemonDeps): TelegramDaemonControlDeps {
3055
+ // The legacy signal seam has no authority metadata of its own. Pair it with
3056
+ // the captured process incarnation so the controller retains its identity
3057
+ // fence; Windows continues through its hard-authority refusal path.
3058
+ const cooperativeSignalReference =
3059
+ deps.sendSignal && (deps.platform ?? process.platform) !== "win32"
3060
+ ? (pid: number): DaemonProcessReference | undefined => {
3061
+ const incarnation = deps.pidIncarnation?.(pid);
3062
+ if (!isProcessIncarnation(incarnation)) return undefined;
3063
+ return {
3064
+ incarnation,
3065
+ termination: "cooperative",
3066
+ signalRoot: signal => deps.sendSignal!(pid, signal),
3067
+ };
3068
+ }
3069
+ : undefined;
2366
3070
  return {
2367
3071
  fs: deps.fs,
2368
3072
  now: deps.now,
2369
3073
  pidAlive: deps.pidAlive,
2370
- processReference: deps.processReference,
3074
+ processReference: cooperativeSignalReference ?? deps.processReference,
2371
3075
  pidIncarnation: deps.pidIncarnation,
3076
+ platform: deps.platform,
2372
3077
  spawn: deps.spawn,
2373
3078
  execPath: deps.execPath,
2374
3079
  ownerPid: deps.pid,
@@ -2963,6 +3668,12 @@ export class TelegramNotificationDaemon {
2963
3668
  private readonly botApi: BotApi;
2964
3669
  private readonly effects = new TelegramEffectSupervisor();
2965
3670
  private readonly topics = new TopicRegistry();
3671
+ /**
3672
+ * Telegram may accept createForumTopic while returning an unusable success
3673
+ * payload. Remember that ambiguity per endpoint so later frames cannot repeat
3674
+ * the external side effect. Explicit API failures remain retryable.
3675
+ */
3676
+ readonly #malformedTopicCreateEndpoints = new Map<string, string>();
2966
3677
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
2967
3678
  /** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
2968
3679
  private readonly droppedSessions = new WeakSet<SessionSocket>();
@@ -3830,13 +4541,20 @@ export class TelegramNotificationDaemon {
3830
4541
  const paths = daemonPaths(this.opts.settings.getAgentDir());
3831
4542
  const rootState = await readJson<{ roots?: string[] }>(this.fsImpl, paths.roots);
3832
4543
  const endpointSessionIds = new Set<string>();
4544
+ // Permanent absences prune; only transient I/O keeps orphan reconciliation
4545
+ // gated (so one deleted worktree cannot disable cleanup forever) (#2956).
3833
4546
  let allRootsReadable = true;
4547
+ const permanentlyMissingRoots: string[] = [];
3834
4548
  for (const root of rootState?.roots ?? []) {
3835
4549
  const dir = path.join(root, "sdk");
3836
4550
  let files: string[];
3837
4551
  try {
3838
4552
  files = await this.fsImpl.readdir(dir);
3839
- } catch {
4553
+ } catch (error) {
4554
+ if (isPermanentMissingPathError(error)) {
4555
+ permanentlyMissingRoots.push(root);
4556
+ continue;
4557
+ }
3840
4558
  allRootsReadable = false;
3841
4559
  continue;
3842
4560
  }
@@ -3885,6 +4603,27 @@ export class TelegramNotificationDaemon {
3885
4603
  } catch {}
3886
4604
  }
3887
4605
  }
4606
+ if (permanentlyMissingRoots.length > 0) {
4607
+ try {
4608
+ await pruneMissingNotificationRoots({
4609
+ settings: this.opts.settings,
4610
+ fs: this.fsImpl,
4611
+ candidates: permanentlyMissingRoots,
4612
+ });
4613
+ } catch (error) {
4614
+ logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`);
4615
+ }
4616
+ }
4617
+ // Best-effort periodic reap of retained exact-unlink quarantines (#2956).
4618
+ try {
4619
+ await reapStaleNotificationArtifacts({
4620
+ settings: this.opts.settings,
4621
+ fs: this.fsImpl,
4622
+ now: this.opts.now,
4623
+ });
4624
+ } catch (error) {
4625
+ logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`);
4626
+ }
3888
4627
  if (allRootsReadable) {
3889
4628
  for (const sessionId of this.topics.sessionIds()) {
3890
4629
  const owner = this.logicalSessionOwners.get(sessionId);
@@ -3978,18 +4717,15 @@ export class TelegramNotificationDaemon {
3978
4717
  );
3979
4718
  } catch {}
3980
4719
  }
3981
- // Preserve the transport-session contract: ordinary endpoints own a topic
3982
- // as soon as they connect. A later authenticated logical rekey is the only
3983
- // path that may recover a different logical session's durable topic.
3984
4720
  void (async () => {
3985
4721
  if (this.#logicalSessionId(session) !== sessionId) return;
3986
- const topicId = await this.ensureTopic(sessionId, this.topicNameFor(sessionId, {}), session);
4722
+ const topic = this.topics.get(sessionId);
4723
+ if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
3987
4724
  const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId);
3988
- if (topicId && topicLease?.topicId === topicId)
3989
- await this.flushPendingThreadedFrames(sessionId, topicLease);
4725
+ if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease);
3990
4726
  })().catch(err =>
3991
4727
  logger.warn(
3992
- `notifications: Telegram initial topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4728
+ `notifications: Telegram topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
3993
4729
  ),
3994
4730
  );
3995
4731
  });
@@ -4870,19 +5606,16 @@ export class TelegramNotificationDaemon {
4870
5606
  this.legacyTopicOwners.set(previousSessionId, session);
4871
5607
  this.preservedInitiatorTopics.add(previousSessionId);
4872
5608
  }
4873
- if (candidateSessionId === session.sessionId && !this.topics.get(candidateSessionId))
5609
+ if (candidateSessionId === session.sessionId)
4874
5610
  void (async () => {
4875
- const topicId = await this.ensureTopic(
4876
- candidateSessionId,
4877
- this.topicNameFor(candidateSessionId, {}),
4878
- session,
4879
- );
5611
+ const topic = this.topics.get(candidateSessionId);
5612
+ if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return;
4880
5613
  const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId);
4881
- if (topicId && topicLease?.topicId === topicId)
5614
+ if (topicLease?.topicId === topic.topicId)
4882
5615
  await this.flushPendingThreadedFrames(candidateSessionId, topicLease);
4883
5616
  })().catch(err =>
4884
5617
  logger.warn(
4885
- `notifications: Telegram recovered topic creation failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
5618
+ `notifications: Telegram recovered topic reattach flush failed: ${sanitizeDiagnostic(String(err), this.opts.botToken)}`,
4886
5619
  ),
4887
5620
  );
4888
5621
  return true;
@@ -5117,6 +5850,9 @@ export class TelegramNotificationDaemon {
5117
5850
  if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
5118
5851
  const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
5119
5852
  if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
5853
+ const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
5854
+ if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
5855
+ throw new Error("createForumTopic: invalid message_thread_id");
5120
5856
  const existing = this.topics.get(sessionId);
5121
5857
  if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
5122
5858
  if (existing) return existing.topicId;
@@ -5140,10 +5876,17 @@ export class TelegramNotificationDaemon {
5140
5876
  throw new Error("topic authority was revoked during creation");
5141
5877
  const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
5142
5878
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
5143
- const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
5144
- if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
5879
+ const response = res as {
5880
+ ok?: unknown;
5881
+ result?: { message_thread_id?: unknown };
5882
+ };
5883
+ const tid = response.result?.message_thread_id;
5884
+ if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
5885
+ if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
5145
5886
  throw new Error("createForumTopic: invalid message_thread_id");
5887
+ }
5146
5888
  acceptedTopicId = String(tid);
5889
+ this.#malformedTopicCreateEndpoints.delete(sessionId);
5147
5890
  if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5148
5891
  if (
5149
5892
  !this.topics.fenceAcceptedCreateForLease(
@@ -6476,21 +7219,6 @@ export class TelegramNotificationDaemon {
6476
7219
  const latestIdentity = identityIndex < 0 ? undefined : replayed[identityIndex];
6477
7220
  const replayIdentitySessionId = latestIdentity?.sessionId as string | undefined;
6478
7221
  const endpointBinding = this.#endpointBinding(session);
6479
- // An open transport owns an eager topic-create claim. An identity-less replay
6480
- // that races that claim must await its own public creation before classifying
6481
- // endpoint authority; otherwise the registry correctly sees an in-flight
6482
- // claim as ambiguous and rejects a valid bootstrap.
6483
- if (!this.topics.get(session.sessionId) && this.#ownsLiveOpenEndpoint(session, endpointBinding)) {
6484
- try {
6485
- await this.ensureTopic(session.sessionId, this.topicNameFor(session.sessionId, {}), session);
6486
- } catch (error) {
6487
- logger.warn(
6488
- `notifications: Telegram replay topic creation failed: ${sanitizeDiagnostic(String(error), this.opts.botToken)}`,
6489
- );
6490
- this.dropSession(session, "replay_topic_creation_failed");
6491
- return;
6492
- }
6493
- }
6494
7222
  // Identity-less replay may resume only the exact transport owner. A
6495
7223
  // rekeyed A→B transport remains denied unless replay proves B.
6496
7224
  const endpointAuthority = this.#endpointAuthority(endpointBinding, session);
@@ -6974,6 +7702,7 @@ export class TelegramNotificationDaemon {
6974
7702
  id: msg.id,
6975
7703
  question: msg.question,
6976
7704
  options: msg.options,
7705
+ recommendedIndex: msg.recommendedIndex,
6977
7706
  controls,
6978
7707
  summary: msg.summary,
6979
7708
  });
@@ -7025,6 +7754,7 @@ export class TelegramNotificationDaemon {
7025
7754
  kind,
7026
7755
  question: msg.question,
7027
7756
  options: msg.options,
7757
+ recommendedIndex: msg.recommendedIndex,
7028
7758
  summary: msg.summary,
7029
7759
  }),
7030
7760
  replyMarkup: kind === "ask" && inline_keyboard.length ? { inline_keyboard } : undefined,
@@ -7818,6 +8548,17 @@ export class TelegramNotificationDaemon {
7818
8548
  ownershipProved = true;
7819
8549
  this.running = !this.stopRequested;
7820
8550
  if (!this.running) return;
8551
+ // Self-heal durable notification state before any scan/poll work so
8552
+ // `daemon reload` recovers dead roots + leak artifacts (#2956).
8553
+ try {
8554
+ await healTelegramDaemonNotificationState({
8555
+ settings: this.opts.settings,
8556
+ fs: this.fsImpl,
8557
+ now: this.opts.now,
8558
+ });
8559
+ } catch (error) {
8560
+ logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`);
8561
+ }
7821
8562
  // Owner-only: start lifecycle control immediately after ownership proof,
7822
8563
  // before timers or pre-poll startup work can invalidate this run.
7823
8564
  // Best-effort; notification delivery remains available on failure.