@gajae-code/coding-agent 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +20 -0
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  9. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  12. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  13. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  14. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  15. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  16. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  18. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  19. package/dist/types/main.d.ts +2 -2
  20. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  22. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  23. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
  26. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  27. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  28. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  29. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  30. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  31. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  32. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  33. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  34. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  35. package/dist/types/sdk/broker/broker.d.ts +1 -0
  36. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  37. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  38. package/dist/types/sdk/broker/internal.d.ts +3 -0
  39. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  40. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  41. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  43. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  44. package/dist/types/sdk/bus/config.d.ts +22 -0
  45. package/dist/types/sdk/bus/index.d.ts +162 -2
  46. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  47. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  48. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  52. package/dist/types/sdk/session.d.ts +16 -1
  53. package/dist/types/secrets/index.d.ts +4 -2
  54. package/dist/types/secrets/obfuscator.d.ts +1 -1
  55. package/dist/types/session/agent-session.d.ts +31 -2
  56. package/dist/types/session/artifacts.d.ts +30 -8
  57. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  58. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  59. package/dist/types/session/session-manager.d.ts +88 -18
  60. package/dist/types/session/session-storage.d.ts +27 -0
  61. package/dist/types/session/streaming-output.d.ts +4 -0
  62. package/dist/types/task/executor.d.ts +9 -0
  63. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  64. package/dist/types/tools/ask.d.ts +87 -12
  65. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  66. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  67. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  68. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  69. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  70. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/path-utils.d.ts +9 -14
  73. package/dist/types/tools/render-utils.d.ts +1 -8
  74. package/dist/types/tools/resource-gc.d.ts +14 -0
  75. package/dist/types/utils/image-loading.d.ts +23 -0
  76. package/dist/types/utils/image-resize.d.ts +2 -0
  77. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  78. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  79. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  80. package/dist/types/web/scrapers/types.d.ts +1 -5
  81. package/package.json +25 -9
  82. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  83. package/scripts/run-threat-model-check.ts +60 -0
  84. package/src/cli/notify-cli.ts +40 -18
  85. package/src/cli/worktree-cli.ts +9 -287
  86. package/src/cli.ts +10 -9
  87. package/src/commands/gc.ts +11 -1
  88. package/src/commands/sdk.ts +10 -4
  89. package/src/commands/worktree.ts +10 -53
  90. package/src/config/model-profiles.ts +15 -15
  91. package/src/config/settings-schema.ts +17 -0
  92. package/src/daemon/runtime.ts +2 -2
  93. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  94. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  95. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  96. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  97. package/src/eval/js/shared/runtime.ts +1 -1
  98. package/src/eval/py/index.ts +2 -2
  99. package/src/export/html/template.generated.ts +1 -1
  100. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  101. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  102. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  103. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  104. package/src/gjc-runtime/gc-render.ts +19 -1
  105. package/src/gjc-runtime/gc-runtime.ts +68 -3
  106. package/src/gjc-runtime/launch-tmux.ts +61 -6
  107. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  108. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  109. package/src/gjc-runtime/state-argv.ts +292 -0
  110. package/src/gjc-runtime/state-runtime.ts +80 -222
  111. package/src/gjc-runtime/team-runtime.ts +1 -1
  112. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  113. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  114. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  115. package/src/harness-control-plane/owner.ts +59 -3
  116. package/src/hindsight/client.ts +65 -2
  117. package/src/internal-urls/agent-protocol.ts +76 -5
  118. package/src/internal-urls/docs-index.generated.ts +7 -6
  119. package/src/internal-urls/local-protocol.ts +416 -18
  120. package/src/lsp/index.ts +4 -88
  121. package/src/main.ts +37 -13
  122. package/src/modes/acp/acp-agent.ts +8 -0
  123. package/src/modes/components/ansi-display-validator.ts +54 -0
  124. package/src/modes/components/custom-editor.ts +103 -32
  125. package/src/modes/components/custom-provider-wizard.ts +26 -1
  126. package/src/modes/components/notifications-settings-editor.ts +26 -2
  127. package/src/modes/components/session-observer-overlay.ts +40 -37
  128. package/src/modes/components/settings-selector.ts +3 -1
  129. package/src/modes/components/tool-transcript-format.ts +393 -0
  130. package/src/modes/components/transcript-viewer-overlay.ts +63 -1
  131. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  132. package/src/modes/controllers/input-controller.ts +98 -37
  133. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  134. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  135. package/src/modes/controllers/selector-controller.ts +71 -9
  136. package/src/modes/interactive-mode.ts +14 -2
  137. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  138. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  139. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  140. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  141. package/src/plan-mode/approved-plan.ts +3 -1
  142. package/src/prompts/agents/architect.md +1 -0
  143. package/src/prompts/agents/critic.md +1 -0
  144. package/src/prompts/system/custom-system-prompt.md +1 -1
  145. package/src/prompts/system/system-prompt.md +2 -1
  146. package/src/prompts/tools/lsp.md +3 -3
  147. package/src/runtime-mcp/manager.ts +16 -1
  148. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  149. package/src/sdk/broker/broker.ts +139 -32
  150. package/src/sdk/broker/discovery.ts +125 -0
  151. package/src/sdk/broker/ensure.ts +103 -19
  152. package/src/sdk/broker/internal.ts +10 -0
  153. package/src/sdk/broker/session-index.ts +309 -38
  154. package/src/sdk/broker/state-version.ts +5 -2
  155. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  156. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  157. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  158. package/src/sdk/bus/config-commands.ts +22 -0
  159. package/src/sdk/bus/config.ts +37 -0
  160. package/src/sdk/bus/index.ts +384 -92
  161. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  162. package/src/sdk/bus/notification-service.ts +284 -62
  163. package/src/sdk/bus/recent-activity.ts +14 -5
  164. package/src/sdk/bus/session-control.ts +150 -44
  165. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  166. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  167. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  168. package/src/sdk/bus/telegram-daemon.ts +1842 -281
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/session.ts +155 -27
  171. package/src/secrets/index.ts +53 -10
  172. package/src/secrets/obfuscator.ts +21 -18
  173. package/src/secrets/regex.ts +2 -0
  174. package/src/session/agent-session.ts +495 -253
  175. package/src/session/artifacts.ts +188 -23
  176. package/src/session/context-estimation.ts +7 -2
  177. package/src/session/internal/managed-session-scope.ts +215 -39
  178. package/src/session/internal/managed-session-storage.ts +1152 -46
  179. package/src/session/session-manager.ts +1055 -148
  180. package/src/session/session-storage.ts +109 -11
  181. package/src/session/streaming-output.ts +6 -0
  182. package/src/task/executor.ts +71 -26
  183. package/src/task/index.ts +63 -20
  184. package/src/tool-discovery/tool-index.ts +18 -0
  185. package/src/tools/ask.ts +329 -57
  186. package/src/tools/bash-allowed-prefixes.ts +79 -29
  187. package/src/tools/bash-skill-urls.ts +14 -3
  188. package/src/tools/bash.ts +2 -2
  189. package/src/tools/browser/launch.ts +3 -1
  190. package/src/tools/browser/readable.ts +1 -1
  191. package/src/tools/browser/screenshot-format.ts +34 -0
  192. package/src/tools/browser/tab-protocol.ts +3 -0
  193. package/src/tools/browser/tab-supervisor.ts +125 -122
  194. package/src/tools/browser/tab-worker-entry.ts +33 -14
  195. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  196. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  197. package/src/tools/browser/tab-worker.ts +7 -5
  198. package/src/tools/index.ts +4 -0
  199. package/src/tools/path-utils.ts +42 -35
  200. package/src/tools/plan-mode-guard.ts +1 -0
  201. package/src/tools/read.ts +7 -1
  202. package/src/tools/render-utils.ts +1 -27
  203. package/src/tools/resource-gc.ts +123 -34
  204. package/src/tools/sqlite-reader.ts +3 -2
  205. package/src/tools/subagent.ts +2 -0
  206. package/src/utils/image-loading.ts +85 -34
  207. package/src/utils/image-resize.ts +16 -9
  208. package/src/utils/pasted-image-loading.ts +514 -0
  209. package/src/utils/pasted-image-path.ts +223 -98
  210. package/src/web/scrapers/html-to-markdown.ts +68 -0
  211. package/src/web/scrapers/readthedocs.ts +1 -1
  212. package/src/web/scrapers/types.ts +1 -68
  213. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -9,7 +9,9 @@
9
9
  */
10
10
 
11
11
  import * as fs from "node:fs";
12
+ import * as os from "node:os";
12
13
  import * as path from "node:path";
14
+ import { Process } from "@gajae-code/natives";
13
15
  import type { Settings } from "../../config/settings";
14
16
  import type {
15
17
  BuiltInDaemonController,
@@ -22,18 +24,31 @@ import type {
22
24
  } from "../../daemon/control-types";
23
25
  import { OWNERSHIP_MISMATCH_MESSAGE, ownershipMismatchRecovery } from "../../daemon/operator-contract";
24
26
  import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
27
+ import { isProcessIncarnation } from "../broker/process-incarnation";
25
28
  import { getNotificationConfig, isTelegramConfigured, tokenFingerprint } from "./config";
29
+ import { exactUnlinkNotificationFile, readNotificationEndpointFile } from "./notification-service";
26
30
  import {
31
+ confirmTelegramDaemonSpawn,
32
+ type DaemonState,
27
33
  daemonPaths,
34
+ hasSafeDaemonStateShape,
35
+ isCurrentCompatibleOwner,
28
36
  isFreshLiveOwner,
37
+ isSignalableMatchingOwner,
29
38
  readDaemonRoots,
30
39
  readDaemonState,
31
40
  spawnTelegramDaemonOwner,
32
41
  type TelegramDaemonDeps,
33
42
  type TelegramDaemonFs,
43
+ type TelegramSpawnOwnerResult,
34
44
  } from "./telegram-daemon";
35
45
 
36
- const nodeFs: TelegramDaemonFs = fs.promises as unknown as TelegramDaemonFs;
46
+ const nodeFs: TelegramDaemonFs = {
47
+ ...(fs.promises as unknown as TelegramDaemonFs),
48
+ readEndpointFile: readNotificationEndpointFile,
49
+ exactUnlink: async (file, identity) =>
50
+ exactUnlinkNotificationFile(file, identity, `.gjc-delete-daemon-transition-${crypto.randomUUID()}.json`),
51
+ };
37
52
  const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
38
53
  const DEFAULT_KILL_TIMEOUT_MS = 3_000;
39
54
  const DEFAULT_WAIT_STEP_MS = 25;
@@ -92,11 +107,41 @@ export async function clearTelegramControlRequest(
92
107
  await fsImpl.unlink(file).catch(() => undefined);
93
108
  }
94
109
 
110
+ export interface DaemonProcessReference {
111
+ incarnation: string;
112
+ signalRoot(signal: NodeJS.Signals): void;
113
+ }
114
+
115
+ function defaultProcessReference(pid: number, platform = os.platform()): DaemonProcessReference | undefined {
116
+ // Process.fromPid on Darwin still resolves identity and then signals a numeric
117
+ // PID. A process can exit and its PID be reused in that interval, so daemon
118
+ // control must not use it as privileged signaling authority.
119
+ if (platform === "darwin") return undefined;
120
+ try {
121
+ const processRef = Process.fromPid(pid);
122
+ if (!processRef || !isProcessIncarnation(processRef.incarnation)) return undefined;
123
+ return {
124
+ incarnation: processRef.incarnation,
125
+ signalRoot: signal => {
126
+ const nativeSignal = os.constants.signals[signal];
127
+ if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
128
+ const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
129
+ if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
130
+ },
131
+ };
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
95
137
  export interface TelegramDaemonControlDeps {
96
138
  fs?: TelegramDaemonFs;
97
139
  now?: () => number;
98
140
  pidAlive?: (pid: number) => boolean;
99
- sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
141
+ processReference?: (pid: number) => DaemonProcessReference | undefined;
142
+ pidIncarnation?: (pid: number) => string | undefined;
143
+ /** Test seam for platform-specific default stable-process authority. */
144
+ platform?: NodeJS.Platform;
100
145
  spawn?: TelegramDaemonDeps["spawn"];
101
146
  execPath?: string;
102
147
  /**
@@ -110,8 +155,14 @@ export interface TelegramDaemonControlDeps {
110
155
  randomId?: () => string;
111
156
  sleep?: (ms: number) => Promise<void>;
112
157
  waitStepMs?: number;
158
+ /** Bounded startup-readiness timeout; injectable for deterministic controller tests. */
159
+ readinessTimeoutMs?: number;
113
160
  }
114
161
 
162
+ export type TelegramGenerationReloadResult =
163
+ | { outcome: "ready"; operation: DaemonOperationResult }
164
+ | { outcome: "failed"; operation: DaemonOperationResult };
165
+
115
166
  function defaultPidAlive(pid: number): boolean {
116
167
  try {
117
168
  process.kill(pid, 0);
@@ -121,20 +172,12 @@ function defaultPidAlive(pid: number): boolean {
121
172
  }
122
173
  }
123
174
 
124
- function defaultSendSignal(pid: number, signal: NodeJS.Signals): void {
125
- try {
126
- process.kill(pid, signal);
127
- } catch {
128
- // Best-effort: the process may already be gone.
129
- }
130
- }
131
-
132
175
  export class TelegramDaemonController implements BuiltInDaemonController {
133
176
  readonly kind = "telegram" as const;
134
177
  private readonly fsImpl: TelegramDaemonFs;
135
- private readonly now: () => number;
136
178
  private readonly pidAlive: (pid: number) => boolean;
137
- private readonly sendSignal: (pid: number, signal: NodeJS.Signals) => void;
179
+ private readonly now: () => number;
180
+ private readonly processReference: (pid: number) => DaemonProcessReference | undefined;
138
181
  private readonly waitStepMs: number;
139
182
 
140
183
  constructor(
@@ -144,7 +187,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
144
187
  this.fsImpl = deps.fs ?? nodeFs;
145
188
  this.now = deps.now ?? Date.now;
146
189
  this.pidAlive = deps.pidAlive ?? defaultPidAlive;
147
- this.sendSignal = deps.sendSignal ?? defaultSendSignal;
190
+ this.processReference = deps.processReference ?? (pid => defaultProcessReference(pid, deps.platform));
148
191
  this.waitStepMs = deps.waitStepMs ?? DEFAULT_WAIT_STEP_MS;
149
192
  }
150
193
 
@@ -167,18 +210,19 @@ export class TelegramDaemonController implements BuiltInDaemonController {
167
210
  }
168
211
  const state = await readDaemonState(this.settings, this.fsImpl);
169
212
  const roots = await readDaemonRoots(this.settings, this.fsImpl);
170
- const live =
171
- state !== undefined &&
172
- isFreshLiveOwner({
173
- state,
174
- now: this.now(),
175
- tokenFingerprint: tokenFingerprint(cfg.botToken as string),
176
- chatId: cfg.chatId as string,
177
- pidAlive: this.pidAlive,
178
- });
179
- let health: DaemonHealth = "stopped";
180
- if (live) health = "running";
181
- else if (state && state.stoppedAt === undefined) health = "stale";
213
+ const health: DaemonHealth =
214
+ !state || state.stoppedAt !== undefined || !this.pidAlive(state.pid)
215
+ ? "stopped"
216
+ : isCurrentCompatibleOwner({
217
+ state,
218
+ now: this.now(),
219
+ tokenFingerprint: tokenFingerprint(cfg.botToken as string),
220
+ chatId: cfg.chatId as string,
221
+ pidAlive: this.pidAlive,
222
+ pidIncarnation: this.deps.pidIncarnation,
223
+ })
224
+ ? "running"
225
+ : "stale";
182
226
  return {
183
227
  kind: this.kind,
184
228
  configured: true,
@@ -198,6 +242,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
198
242
  fs: this.deps.fs,
199
243
  now: this.deps.now,
200
244
  pidAlive: this.deps.pidAlive,
245
+ pidIncarnation: this.deps.pidIncarnation,
201
246
  pid: this.deps.ownerPid ?? process.ppid,
202
247
  spawn: this.deps.spawn,
203
248
  execPath: this.deps.execPath,
@@ -205,6 +250,32 @@ export class TelegramDaemonController implements BuiltInDaemonController {
205
250
  };
206
251
  }
207
252
 
253
+ private async spawnAndWait(
254
+ roots: string[],
255
+ token: string,
256
+ chatId: string,
257
+ ): Promise<{ spawned: TelegramSpawnOwnerResult; ready: boolean }> {
258
+ const spawned = await spawnTelegramDaemonOwner(
259
+ { settings: this.settings, roots, tokenFingerprint: token, chatId },
260
+ this.spawnDeps(),
261
+ );
262
+ const ready = await confirmTelegramDaemonSpawn({
263
+ settings: this.settings,
264
+ spawned,
265
+ tokenFingerprint: token,
266
+ chatId,
267
+ pid: this.deps.ownerPid ?? process.ppid,
268
+ fs: this.fsImpl,
269
+ now: this.now,
270
+ pidAlive: this.pidAlive,
271
+ pidIncarnation: this.deps.pidIncarnation,
272
+ sleep: this.deps.sleep,
273
+ waitStepMs: this.waitStepMs,
274
+ timeoutMs: this.deps.readinessTimeoutMs,
275
+ });
276
+ return { spawned, ready };
277
+ }
278
+
208
279
  private sleep(ms: number): Promise<void> {
209
280
  if (this.deps.sleep) return this.deps.sleep(ms);
210
281
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -217,14 +288,67 @@ export class TelegramDaemonController implements BuiltInDaemonController {
217
288
  */
218
289
  private async waitForPidDeath(pid: number, timeoutMs: number): Promise<boolean> {
219
290
  if (!this.pidAlive(pid)) return true;
220
- const deadline = this.now() + timeoutMs;
221
- while (this.now() < deadline) {
222
- await this.sleep(this.waitStepMs);
291
+ const timeout = Math.max(timeoutMs, 0);
292
+ const waitStepMs = Math.max(this.waitStepMs, 1);
293
+ const deadline = this.now() + timeout;
294
+ const maxPolls = Math.ceil(timeout / waitStepMs);
295
+ for (let poll = 0; poll < maxPolls && this.now() < deadline; poll++) {
296
+ await this.sleep(waitStepMs);
223
297
  if (!this.pidAlive(pid)) return true;
224
298
  }
225
299
  return !this.pidAlive(pid);
226
300
  }
227
301
 
302
+ private async signalCapturedOwner(
303
+ captured: DaemonState,
304
+ tokenFingerprint: string,
305
+ chatId: string,
306
+ signal: NodeJS.Signals,
307
+ ): Promise<"signaled" | "already_gone" | "ownership_changed"> {
308
+ const current = await readDaemonState(this.settings, this.fsImpl);
309
+ // The signal is a privileged action: immediately before sending it, prove the
310
+ // complete state record is still the exact captured owner. A malformed record,
311
+ // configuration mutation, or successor using a reused PID is never signalable.
312
+ if (
313
+ !hasSafeDaemonStateShape(current) ||
314
+ current.ownerId !== captured.ownerId ||
315
+ current.acquisitionId !== captured.acquisitionId ||
316
+ current.pid !== captured.pid ||
317
+ current.generation !== captured.generation ||
318
+ current.incarnation !== captured.incarnation ||
319
+ current.tokenFingerprint !== captured.tokenFingerprint ||
320
+ current.chatId !== captured.chatId ||
321
+ current.tokenFingerprint !== tokenFingerprint ||
322
+ current.chatId !== chatId
323
+ )
324
+ return "ownership_changed";
325
+ // A matching captured owner that exited between the request and recheck has
326
+ // completed the handoff. A live owner with changed or unavailable provenance
327
+ // remains ambiguous and must not be treated as stopped.
328
+ if (!this.pidAlive(captured.pid)) return "already_gone";
329
+ if (
330
+ !isSignalableMatchingOwner({
331
+ state: current,
332
+ tokenFingerprint,
333
+ chatId,
334
+ pidAlive: this.pidAlive,
335
+ pidIncarnation: this.deps.pidIncarnation,
336
+ })
337
+ )
338
+ return "ownership_changed";
339
+ const processRef = this.processReference(captured.pid);
340
+ // A stable native process reference (pidfd/handle/start-time reference) closes
341
+ // the exit-and-reuse window between ordinary provenance checks and signaling.
342
+ // Its identity must still be the exact persisted incarnation before use.
343
+ if (!processRef || processRef.incarnation !== captured.incarnation) return "ownership_changed";
344
+ try {
345
+ processRef.signalRoot(signal);
346
+ } catch {
347
+ return "already_gone";
348
+ }
349
+ return "signaled";
350
+ }
351
+
228
352
  private result(
229
353
  action: "stop" | "reload",
230
354
  ok: boolean,
@@ -241,6 +365,13 @@ export class TelegramDaemonController implements BuiltInDaemonController {
241
365
  return this.stopOrReload("reload", opts);
242
366
  }
243
367
 
368
+ async reloadForGenerationUpgrade(opts: DaemonOperationOptions = {}): Promise<TelegramGenerationReloadResult> {
369
+ const operation = await this.stopOrReload("reload", opts);
370
+ if (!operation.ok) return { outcome: "failed", operation };
371
+ const after = await this.status();
372
+ return after.health === "running" ? { outcome: "ready", operation } : { outcome: "failed", operation };
373
+ }
374
+
244
375
  async stop(opts: DaemonOperationOptions = {}): Promise<DaemonOperationResult> {
245
376
  return this.stopOrReload("stop", opts);
246
377
  }
@@ -259,19 +390,55 @@ export class TelegramDaemonController implements BuiltInDaemonController {
259
390
  const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS;
260
391
  const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
261
392
 
262
- // No running owner.
263
- if (before.health !== "running") {
264
- if (action === "stop") {
393
+ const state = await readDaemonState(this.settings, this.fsImpl);
394
+ const replaceableLiveOwner =
395
+ (action === "reload" &&
396
+ state !== undefined &&
397
+ isFreshLiveOwner({
398
+ state,
399
+ now: this.now(),
400
+ tokenFingerprint: fp,
401
+ chatId,
402
+ pidAlive: this.pidAlive,
403
+ pidIncarnation: this.deps.pidIncarnation,
404
+ }) &&
405
+ isSignalableMatchingOwner({
406
+ state,
407
+ tokenFingerprint: fp,
408
+ chatId,
409
+ pidAlive: this.pidAlive,
410
+ pidIncarnation: this.deps.pidIncarnation,
411
+ })) ||
412
+ // A physically-live matching owner whose heartbeat is stale (hung) may be
413
+ // past-TTL yet still holding the poller. Autostart/generation-upgrade reloads
414
+ // stay conservative and refuse it, but an explicit `reload --force` must be
415
+ // able to signal and replace it rather than deadlock behind the live PID.
416
+ (opts.force === true &&
417
+ isSignalableMatchingOwner({
418
+ state,
419
+ tokenFingerprint: fp,
420
+ chatId,
421
+ pidAlive: this.pidAlive,
422
+ pidIncarnation: this.deps.pidIncarnation,
423
+ }));
424
+ const stoppableLiveOwner =
425
+ action === "stop" &&
426
+ isSignalableMatchingOwner({
427
+ state,
428
+ tokenFingerprint: fp,
429
+ chatId,
430
+ pidAlive: this.pidAlive,
431
+ pidIncarnation: this.deps.pidIncarnation,
432
+ });
433
+ // A stale pre-generation owner may only be moved by reload; manual stop
434
+ // also targets a physically live matching legacy owner, but never spawns.
435
+ if (before.health !== "running" && !replaceableLiveOwner && !stoppableLiveOwner) {
436
+ if (action === "stop")
265
437
  return this.result(action, true, "no running telegram daemon", before, before, warnings);
266
- }
267
- const spawnIfStopped = opts.spawnIfStopped ?? true;
268
- if (!spawnIfStopped) {
438
+ if (!(opts.spawnIfStopped ?? true)) {
269
439
  return this.result(action, true, "no running telegram daemon to reload", before, before, warnings);
270
440
  }
271
- const spawned = await spawnTelegramDaemonOwner(
272
- { settings: this.settings, roots, tokenFingerprint: fp, chatId },
273
- this.spawnDeps(),
274
- );
441
+ const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
275
442
  warnings.push(...spawned.warnings);
276
443
  const after = await this.status();
277
444
  if (spawned.result === "blocked") {
@@ -287,8 +454,10 @@ export class TelegramDaemonController implements BuiltInDaemonController {
287
454
  }
288
455
  return this.result(
289
456
  action,
290
- spawned.result === "owner_spawned",
291
- `spawned fresh telegram daemon (${spawned.result})`,
457
+ spawned.result === "owner_spawned" && ready && after.health === "running",
458
+ ready
459
+ ? `spawned fresh telegram daemon (${spawned.result})`
460
+ : "telegram daemon did not become ready after spawning",
292
461
  before,
293
462
  after,
294
463
  warnings,
@@ -296,15 +465,48 @@ export class TelegramDaemonController implements BuiltInDaemonController {
296
465
  }
297
466
 
298
467
  // Running owner: capture identity, request cooperative stop, signal, wait.
299
- const oldOwnerId = before.ownerId as string;
300
- const oldPid = before.pid as number;
468
+ if (
469
+ !hasSafeDaemonStateShape(state) ||
470
+ !isSignalableMatchingOwner({
471
+ state,
472
+ tokenFingerprint: fp,
473
+ chatId,
474
+ pidAlive: this.pidAlive,
475
+ pidIncarnation: this.deps.pidIncarnation,
476
+ })
477
+ ) {
478
+ return this.result(
479
+ action,
480
+ false,
481
+ "telegram daemon ownership changed; refusing to signal",
482
+ before,
483
+ before,
484
+ warnings,
485
+ );
486
+ }
487
+ const capturedOwner = state;
488
+ const oldOwnerId = capturedOwner.ownerId;
489
+ const oldPid = capturedOwner.pid;
301
490
  const requestId = this.deps.randomId?.() ?? `${this.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
302
491
  await writeTelegramControlRequest(
303
492
  this.settings,
304
493
  { version: 1, requestId, action, ownerId: oldOwnerId, pid: oldPid, createdAt: this.now() },
305
494
  this.fsImpl,
306
495
  );
307
- if (this.pidAlive(oldPid)) this.sendSignal(oldPid, "SIGTERM");
496
+ if ((await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGTERM")) === "ownership_changed") {
497
+ await this.clearOwnRequest(requestId);
498
+ return this.result(
499
+ action,
500
+ false,
501
+ "telegram daemon ownership changed; refusing to signal",
502
+ before,
503
+ await this.status(),
504
+ warnings,
505
+ );
506
+ }
507
+ // "signaled" or "already_gone" (the owner already exited cooperatively): confirm
508
+ // death through the normal path — waitForPidDeath returns immediately for a
509
+ // dead pid, so stop succeeds and reload proceeds to spawn the replacement.
308
510
 
309
511
  let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
310
512
  if (!dead) {
@@ -319,33 +521,36 @@ export class TelegramDaemonController implements BuiltInDaemonController {
319
521
  tokenFingerprint: fp,
320
522
  chatId,
321
523
  pidAlive: this.pidAlive,
524
+ pidIncarnation: this.deps.pidIncarnation,
322
525
  });
323
526
  if (changedToLiveOwner) {
324
- // A different, fresh-live owner already supersedes the old one. Do not
325
- // kill it or spawn another; attach to the running daemon.
326
- await this.clearOwnRequest(requestId, oldOwnerId);
527
+ await this.clearOwnRequest(requestId);
327
528
  const after = await this.status();
328
- warnings.push("ownership changed to a live owner; attached without spawning");
329
- return this.result(
330
- action,
331
- true,
332
- action === "reload"
333
- ? "a live owner already exists; attached instead of reloading"
334
- : "another live owner already exists",
335
- before,
336
- after,
337
- warnings,
338
- );
529
+ if (!dead) {
530
+ return this.result(
531
+ action,
532
+ false,
533
+ "ownership changed before the captured daemon exited; refusing to signal or spawn",
534
+ before,
535
+ after,
536
+ warnings,
537
+ );
538
+ }
339
539
  }
340
540
  // No live replacement. Escalate to SIGKILL only with --force and only when
341
541
  // the captured owner/pid still matches, so we never kill a different owner.
342
542
  const stillSameOwner = current !== undefined && current.ownerId === oldOwnerId && current.pid === oldPid;
343
- if (opts.force && stillSameOwner && this.pidAlive(oldPid)) {
344
- this.sendSignal(oldPid, "SIGKILL");
345
- dead = await this.waitForPidDeath(oldPid, killTimeoutMs);
543
+ if (opts.force && stillSameOwner) {
544
+ const killResult = await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGKILL");
545
+ // Kill only a still-live matching owner; an owner that exited between the
546
+ // graceful timeout and this recheck ("already_gone") is confirmed dead by
547
+ // waitForPidDeath, while a real ownership change stays fenced.
548
+ if (killResult === "signaled" || killResult === "already_gone") {
549
+ dead = await this.waitForPidDeath(oldPid, killTimeoutMs);
550
+ }
346
551
  }
347
552
  if (!dead) {
348
- await this.clearOwnRequest(requestId, oldOwnerId);
553
+ await this.clearOwnRequest(requestId);
349
554
  const after = await this.status();
350
555
  const message = opts.force
351
556
  ? "old daemon did not exit after SIGKILL; refusing to spawn to avoid a Telegram 409 conflict"
@@ -355,22 +560,38 @@ export class TelegramDaemonController implements BuiltInDaemonController {
355
560
  }
356
561
 
357
562
  // Old pid is dead: safe to clear our request and proceed.
358
- await this.clearOwnRequest(requestId, oldOwnerId);
563
+ await this.clearOwnRequest(requestId);
359
564
 
360
565
  if (action === "stop") {
361
566
  const after = await this.status();
362
567
  return this.result(action, true, "stopped telegram daemon", before, after, warnings);
363
568
  }
364
569
 
365
- const spawned = await spawnTelegramDaemonOwner(
366
- { settings: this.settings, roots, tokenFingerprint: fp, chatId },
367
- this.spawnDeps(),
368
- );
570
+ const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
369
571
  warnings.push(...spawned.warnings);
370
572
  const after = await this.status();
371
573
  if (spawned.result === "attached") {
372
- // A live owner already exists; attaching to it is a valid running end-state.
373
- warnings.push("a live owner already exists; attached instead of spawning");
574
+ const attachedState = await readDaemonState(this.settings, this.fsImpl);
575
+ if (
576
+ !isCurrentCompatibleOwner({
577
+ state: attachedState,
578
+ now: this.now(),
579
+ tokenFingerprint: fp,
580
+ chatId,
581
+ pidAlive: this.pidAlive,
582
+ pidIncarnation: this.deps.pidIncarnation,
583
+ })
584
+ ) {
585
+ return this.result(
586
+ action,
587
+ false,
588
+ "ownership moved to a non-ready or incompatible daemon; reload required",
589
+ before,
590
+ after,
591
+ warnings,
592
+ );
593
+ }
594
+ warnings.push("a ready current-compatible owner already exists; attached instead of spawning");
374
595
  } else if (after.ownerId && after.ownerId === oldOwnerId) {
375
596
  warnings.push("owner id unchanged after reload");
376
597
  }
@@ -387,20 +608,17 @@ export class TelegramDaemonController implements BuiltInDaemonController {
387
608
  }
388
609
  return this.result(
389
610
  action,
390
- spawned.result !== "disabled",
391
- `reloaded telegram daemon (${spawned.result})`,
611
+ (spawned.result === "owner_spawned" || spawned.result === "attached") && ready && after.health === "running",
612
+ ready ? `reloaded telegram daemon (${spawned.result})` : "telegram daemon did not become ready after spawning",
392
613
  before,
393
614
  after,
394
615
  warnings,
395
616
  );
396
617
  }
397
618
 
398
- /** Clear our own control request unless a newer owner-scoped request replaced it. */
399
- private async clearOwnRequest(requestId: string, oldOwnerId: string): Promise<void> {
619
+ /** Clear only our exact control request; a successor request must survive. */
620
+ private async clearOwnRequest(requestId: string): Promise<void> {
400
621
  const current = await readTelegramControlRequest(this.settings, this.fsImpl);
401
- if (!current) return;
402
- if (current.requestId === requestId || current.ownerId === oldOwnerId) {
403
- await clearTelegramControlRequest(this.settings, current.requestId, this.fsImpl);
404
- }
622
+ if (current?.requestId === requestId) await clearTelegramControlRequest(this.settings, requestId, this.fsImpl);
405
623
  }
406
624
  }