@gajae-code/coding-agent 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +31 -1
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +98 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. package/src/tools/subagent.ts +2 -0
@@ -9,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,52 @@ 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
+ try {
117
+ const processRef = Process.fromPid(pid);
118
+ if (!processRef || !isProcessIncarnation(processRef.incarnation)) return undefined;
119
+ const incarnation = processRef.incarnation;
120
+ return {
121
+ incarnation,
122
+ signalRoot: signal => {
123
+ const nativeSignal = os.constants.signals[signal];
124
+ if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
125
+ // macOS exposes no pidfd and the native signal_root is a no-op there, so
126
+ // the daemon control plane previously had NO way to signal a live owner —
127
+ // every stop/reload of a live/hung daemon refused ("ownership changed;
128
+ // refusing to signal"), and only an external `kill -9` could recover it.
129
+ // Signal by numeric PID via kill(2), but re-read the immutable start-time
130
+ // incarnation immediately beforehand so a PID that exited and was reused
131
+ // since capture is never signaled; the residual window is the few
132
+ // instructions between this recheck and kill(2).
133
+ if (platform === "darwin") {
134
+ const current = Process.fromPid(pid) as { incarnation?: unknown } | null;
135
+ if (!current || current.incarnation !== incarnation) throw new Error("Pinned process is already gone");
136
+ process.kill(pid, signal);
137
+ return;
138
+ }
139
+ const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
140
+ if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
141
+ },
142
+ };
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
95
148
  export interface TelegramDaemonControlDeps {
96
149
  fs?: TelegramDaemonFs;
97
150
  now?: () => number;
98
151
  pidAlive?: (pid: number) => boolean;
99
- sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
152
+ processReference?: (pid: number) => DaemonProcessReference | undefined;
153
+ pidIncarnation?: (pid: number) => string | undefined;
154
+ /** Test seam for platform-specific default stable-process authority. */
155
+ platform?: NodeJS.Platform;
100
156
  spawn?: TelegramDaemonDeps["spawn"];
101
157
  execPath?: string;
102
158
  /**
@@ -110,8 +166,14 @@ export interface TelegramDaemonControlDeps {
110
166
  randomId?: () => string;
111
167
  sleep?: (ms: number) => Promise<void>;
112
168
  waitStepMs?: number;
169
+ /** Bounded startup-readiness timeout; injectable for deterministic controller tests. */
170
+ readinessTimeoutMs?: number;
113
171
  }
114
172
 
173
+ export type TelegramGenerationReloadResult =
174
+ | { outcome: "ready"; operation: DaemonOperationResult }
175
+ | { outcome: "failed"; operation: DaemonOperationResult };
176
+
115
177
  function defaultPidAlive(pid: number): boolean {
116
178
  try {
117
179
  process.kill(pid, 0);
@@ -121,20 +183,12 @@ function defaultPidAlive(pid: number): boolean {
121
183
  }
122
184
  }
123
185
 
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
186
  export class TelegramDaemonController implements BuiltInDaemonController {
133
187
  readonly kind = "telegram" as const;
134
188
  private readonly fsImpl: TelegramDaemonFs;
135
- private readonly now: () => number;
136
189
  private readonly pidAlive: (pid: number) => boolean;
137
- private readonly sendSignal: (pid: number, signal: NodeJS.Signals) => void;
190
+ private readonly now: () => number;
191
+ private readonly processReference: (pid: number) => DaemonProcessReference | undefined;
138
192
  private readonly waitStepMs: number;
139
193
 
140
194
  constructor(
@@ -144,7 +198,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
144
198
  this.fsImpl = deps.fs ?? nodeFs;
145
199
  this.now = deps.now ?? Date.now;
146
200
  this.pidAlive = deps.pidAlive ?? defaultPidAlive;
147
- this.sendSignal = deps.sendSignal ?? defaultSendSignal;
201
+ this.processReference = deps.processReference ?? (pid => defaultProcessReference(pid, deps.platform));
148
202
  this.waitStepMs = deps.waitStepMs ?? DEFAULT_WAIT_STEP_MS;
149
203
  }
150
204
 
@@ -167,18 +221,19 @@ export class TelegramDaemonController implements BuiltInDaemonController {
167
221
  }
168
222
  const state = await readDaemonState(this.settings, this.fsImpl);
169
223
  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";
224
+ const health: DaemonHealth =
225
+ !state || state.stoppedAt !== undefined || !this.pidAlive(state.pid)
226
+ ? "stopped"
227
+ : isCurrentCompatibleOwner({
228
+ state,
229
+ now: this.now(),
230
+ tokenFingerprint: tokenFingerprint(cfg.botToken as string),
231
+ chatId: cfg.chatId as string,
232
+ pidAlive: this.pidAlive,
233
+ pidIncarnation: this.deps.pidIncarnation,
234
+ })
235
+ ? "running"
236
+ : "stale";
182
237
  return {
183
238
  kind: this.kind,
184
239
  configured: true,
@@ -198,6 +253,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
198
253
  fs: this.deps.fs,
199
254
  now: this.deps.now,
200
255
  pidAlive: this.deps.pidAlive,
256
+ pidIncarnation: this.deps.pidIncarnation,
201
257
  pid: this.deps.ownerPid ?? process.ppid,
202
258
  spawn: this.deps.spawn,
203
259
  execPath: this.deps.execPath,
@@ -205,6 +261,32 @@ export class TelegramDaemonController implements BuiltInDaemonController {
205
261
  };
206
262
  }
207
263
 
264
+ private async spawnAndWait(
265
+ roots: string[],
266
+ token: string,
267
+ chatId: string,
268
+ ): Promise<{ spawned: TelegramSpawnOwnerResult; ready: boolean }> {
269
+ const spawned = await spawnTelegramDaemonOwner(
270
+ { settings: this.settings, roots, tokenFingerprint: token, chatId },
271
+ this.spawnDeps(),
272
+ );
273
+ const ready = await confirmTelegramDaemonSpawn({
274
+ settings: this.settings,
275
+ spawned,
276
+ tokenFingerprint: token,
277
+ chatId,
278
+ pid: this.deps.ownerPid ?? process.ppid,
279
+ fs: this.fsImpl,
280
+ now: this.now,
281
+ pidAlive: this.pidAlive,
282
+ pidIncarnation: this.deps.pidIncarnation,
283
+ sleep: this.deps.sleep,
284
+ waitStepMs: this.waitStepMs,
285
+ timeoutMs: this.deps.readinessTimeoutMs,
286
+ });
287
+ return { spawned, ready };
288
+ }
289
+
208
290
  private sleep(ms: number): Promise<void> {
209
291
  if (this.deps.sleep) return this.deps.sleep(ms);
210
292
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -217,14 +299,67 @@ export class TelegramDaemonController implements BuiltInDaemonController {
217
299
  */
218
300
  private async waitForPidDeath(pid: number, timeoutMs: number): Promise<boolean> {
219
301
  if (!this.pidAlive(pid)) return true;
220
- const deadline = this.now() + timeoutMs;
221
- while (this.now() < deadline) {
222
- await this.sleep(this.waitStepMs);
302
+ const timeout = Math.max(timeoutMs, 0);
303
+ const waitStepMs = Math.max(this.waitStepMs, 1);
304
+ const deadline = this.now() + timeout;
305
+ const maxPolls = Math.ceil(timeout / waitStepMs);
306
+ for (let poll = 0; poll < maxPolls && this.now() < deadline; poll++) {
307
+ await this.sleep(waitStepMs);
223
308
  if (!this.pidAlive(pid)) return true;
224
309
  }
225
310
  return !this.pidAlive(pid);
226
311
  }
227
312
 
313
+ private async signalCapturedOwner(
314
+ captured: DaemonState,
315
+ tokenFingerprint: string,
316
+ chatId: string,
317
+ signal: NodeJS.Signals,
318
+ ): Promise<"signaled" | "already_gone" | "ownership_changed"> {
319
+ const current = await readDaemonState(this.settings, this.fsImpl);
320
+ // The signal is a privileged action: immediately before sending it, prove the
321
+ // complete state record is still the exact captured owner. A malformed record,
322
+ // configuration mutation, or successor using a reused PID is never signalable.
323
+ if (
324
+ !hasSafeDaemonStateShape(current) ||
325
+ current.ownerId !== captured.ownerId ||
326
+ current.acquisitionId !== captured.acquisitionId ||
327
+ current.pid !== captured.pid ||
328
+ current.generation !== captured.generation ||
329
+ current.incarnation !== captured.incarnation ||
330
+ current.tokenFingerprint !== captured.tokenFingerprint ||
331
+ current.chatId !== captured.chatId ||
332
+ current.tokenFingerprint !== tokenFingerprint ||
333
+ current.chatId !== chatId
334
+ )
335
+ return "ownership_changed";
336
+ // A matching captured owner that exited between the request and recheck has
337
+ // completed the handoff. A live owner with changed or unavailable provenance
338
+ // remains ambiguous and must not be treated as stopped.
339
+ if (!this.pidAlive(captured.pid)) return "already_gone";
340
+ if (
341
+ !isSignalableMatchingOwner({
342
+ state: current,
343
+ tokenFingerprint,
344
+ chatId,
345
+ pidAlive: this.pidAlive,
346
+ pidIncarnation: this.deps.pidIncarnation,
347
+ })
348
+ )
349
+ return "ownership_changed";
350
+ const processRef = this.processReference(captured.pid);
351
+ // A stable native process reference (pidfd/handle/start-time reference) closes
352
+ // the exit-and-reuse window between ordinary provenance checks and signaling.
353
+ // Its identity must still be the exact persisted incarnation before use.
354
+ if (!processRef || processRef.incarnation !== captured.incarnation) return "ownership_changed";
355
+ try {
356
+ processRef.signalRoot(signal);
357
+ } catch {
358
+ return "already_gone";
359
+ }
360
+ return "signaled";
361
+ }
362
+
228
363
  private result(
229
364
  action: "stop" | "reload",
230
365
  ok: boolean,
@@ -241,6 +376,20 @@ export class TelegramDaemonController implements BuiltInDaemonController {
241
376
  return this.stopOrReload("reload", opts);
242
377
  }
243
378
 
379
+ async reloadForGenerationUpgrade(opts: DaemonOperationOptions = {}): Promise<TelegramGenerationReloadResult> {
380
+ // A generation upgrade MUST replace an incompatible older-generation owner to
381
+ // avoid a permanent single-poller deadlock. Unlike a manual `gjc daemon
382
+ // reload`, this automatic path force-escalates to SIGKILL when the old owner
383
+ // ignores the cooperative SIGTERM within the graceful timeout, so SDK startup
384
+ // self-recovers instead of failing closed and asking the operator to rerun
385
+ // with --force. The SIGKILL remains fenced to the still-live, still-matching
386
+ // captured owner (same ownerId + pid), so a fresh replacement is never killed.
387
+ const operation = await this.stopOrReload("reload", { ...opts, force: true });
388
+ if (!operation.ok) return { outcome: "failed", operation };
389
+ const after = await this.status();
390
+ return after.health === "running" ? { outcome: "ready", operation } : { outcome: "failed", operation };
391
+ }
392
+
244
393
  async stop(opts: DaemonOperationOptions = {}): Promise<DaemonOperationResult> {
245
394
  return this.stopOrReload("stop", opts);
246
395
  }
@@ -259,19 +408,55 @@ export class TelegramDaemonController implements BuiltInDaemonController {
259
408
  const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS;
260
409
  const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
261
410
 
262
- // No running owner.
263
- if (before.health !== "running") {
264
- if (action === "stop") {
411
+ const state = await readDaemonState(this.settings, this.fsImpl);
412
+ const replaceableLiveOwner =
413
+ (action === "reload" &&
414
+ state !== undefined &&
415
+ isFreshLiveOwner({
416
+ state,
417
+ now: this.now(),
418
+ tokenFingerprint: fp,
419
+ chatId,
420
+ pidAlive: this.pidAlive,
421
+ pidIncarnation: this.deps.pidIncarnation,
422
+ }) &&
423
+ isSignalableMatchingOwner({
424
+ state,
425
+ tokenFingerprint: fp,
426
+ chatId,
427
+ pidAlive: this.pidAlive,
428
+ pidIncarnation: this.deps.pidIncarnation,
429
+ })) ||
430
+ // A physically-live matching owner whose heartbeat is stale (hung) may be
431
+ // past-TTL yet still holding the poller. Autostart/generation-upgrade reloads
432
+ // stay conservative and refuse it, but an explicit `reload --force` must be
433
+ // able to signal and replace it rather than deadlock behind the live PID.
434
+ (opts.force === true &&
435
+ isSignalableMatchingOwner({
436
+ state,
437
+ tokenFingerprint: fp,
438
+ chatId,
439
+ pidAlive: this.pidAlive,
440
+ pidIncarnation: this.deps.pidIncarnation,
441
+ }));
442
+ const stoppableLiveOwner =
443
+ action === "stop" &&
444
+ isSignalableMatchingOwner({
445
+ state,
446
+ tokenFingerprint: fp,
447
+ chatId,
448
+ pidAlive: this.pidAlive,
449
+ pidIncarnation: this.deps.pidIncarnation,
450
+ });
451
+ // A stale pre-generation owner may only be moved by reload; manual stop
452
+ // also targets a physically live matching legacy owner, but never spawns.
453
+ if (before.health !== "running" && !replaceableLiveOwner && !stoppableLiveOwner) {
454
+ if (action === "stop")
265
455
  return this.result(action, true, "no running telegram daemon", before, before, warnings);
266
- }
267
- const spawnIfStopped = opts.spawnIfStopped ?? true;
268
- if (!spawnIfStopped) {
456
+ if (!(opts.spawnIfStopped ?? true)) {
269
457
  return this.result(action, true, "no running telegram daemon to reload", before, before, warnings);
270
458
  }
271
- const spawned = await spawnTelegramDaemonOwner(
272
- { settings: this.settings, roots, tokenFingerprint: fp, chatId },
273
- this.spawnDeps(),
274
- );
459
+ const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
275
460
  warnings.push(...spawned.warnings);
276
461
  const after = await this.status();
277
462
  if (spawned.result === "blocked") {
@@ -287,8 +472,10 @@ export class TelegramDaemonController implements BuiltInDaemonController {
287
472
  }
288
473
  return this.result(
289
474
  action,
290
- spawned.result === "owner_spawned",
291
- `spawned fresh telegram daemon (${spawned.result})`,
475
+ spawned.result === "owner_spawned" && ready && after.health === "running",
476
+ ready
477
+ ? `spawned fresh telegram daemon (${spawned.result})`
478
+ : "telegram daemon did not become ready after spawning",
292
479
  before,
293
480
  after,
294
481
  warnings,
@@ -296,15 +483,48 @@ export class TelegramDaemonController implements BuiltInDaemonController {
296
483
  }
297
484
 
298
485
  // Running owner: capture identity, request cooperative stop, signal, wait.
299
- const oldOwnerId = before.ownerId as string;
300
- const oldPid = before.pid as number;
486
+ if (
487
+ !hasSafeDaemonStateShape(state) ||
488
+ !isSignalableMatchingOwner({
489
+ state,
490
+ tokenFingerprint: fp,
491
+ chatId,
492
+ pidAlive: this.pidAlive,
493
+ pidIncarnation: this.deps.pidIncarnation,
494
+ })
495
+ ) {
496
+ return this.result(
497
+ action,
498
+ false,
499
+ "telegram daemon ownership changed; refusing to signal",
500
+ before,
501
+ before,
502
+ warnings,
503
+ );
504
+ }
505
+ const capturedOwner = state;
506
+ const oldOwnerId = capturedOwner.ownerId;
507
+ const oldPid = capturedOwner.pid;
301
508
  const requestId = this.deps.randomId?.() ?? `${this.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
302
509
  await writeTelegramControlRequest(
303
510
  this.settings,
304
511
  { version: 1, requestId, action, ownerId: oldOwnerId, pid: oldPid, createdAt: this.now() },
305
512
  this.fsImpl,
306
513
  );
307
- if (this.pidAlive(oldPid)) this.sendSignal(oldPid, "SIGTERM");
514
+ if ((await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGTERM")) === "ownership_changed") {
515
+ await this.clearOwnRequest(requestId);
516
+ return this.result(
517
+ action,
518
+ false,
519
+ "telegram daemon ownership changed; refusing to signal",
520
+ before,
521
+ await this.status(),
522
+ warnings,
523
+ );
524
+ }
525
+ // "signaled" or "already_gone" (the owner already exited cooperatively): confirm
526
+ // death through the normal path — waitForPidDeath returns immediately for a
527
+ // dead pid, so stop succeeds and reload proceeds to spawn the replacement.
308
528
 
309
529
  let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
310
530
  if (!dead) {
@@ -319,33 +539,36 @@ export class TelegramDaemonController implements BuiltInDaemonController {
319
539
  tokenFingerprint: fp,
320
540
  chatId,
321
541
  pidAlive: this.pidAlive,
542
+ pidIncarnation: this.deps.pidIncarnation,
322
543
  });
323
544
  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);
545
+ await this.clearOwnRequest(requestId);
327
546
  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
- );
547
+ if (!dead) {
548
+ return this.result(
549
+ action,
550
+ false,
551
+ "ownership changed before the captured daemon exited; refusing to signal or spawn",
552
+ before,
553
+ after,
554
+ warnings,
555
+ );
556
+ }
339
557
  }
340
558
  // No live replacement. Escalate to SIGKILL only with --force and only when
341
559
  // the captured owner/pid still matches, so we never kill a different owner.
342
560
  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);
561
+ if (opts.force && stillSameOwner) {
562
+ const killResult = await this.signalCapturedOwner(capturedOwner, fp, chatId, "SIGKILL");
563
+ // Kill only a still-live matching owner; an owner that exited between the
564
+ // graceful timeout and this recheck ("already_gone") is confirmed dead by
565
+ // waitForPidDeath, while a real ownership change stays fenced.
566
+ if (killResult === "signaled" || killResult === "already_gone") {
567
+ dead = await this.waitForPidDeath(oldPid, killTimeoutMs);
568
+ }
346
569
  }
347
570
  if (!dead) {
348
- await this.clearOwnRequest(requestId, oldOwnerId);
571
+ await this.clearOwnRequest(requestId);
349
572
  const after = await this.status();
350
573
  const message = opts.force
351
574
  ? "old daemon did not exit after SIGKILL; refusing to spawn to avoid a Telegram 409 conflict"
@@ -355,22 +578,38 @@ export class TelegramDaemonController implements BuiltInDaemonController {
355
578
  }
356
579
 
357
580
  // Old pid is dead: safe to clear our request and proceed.
358
- await this.clearOwnRequest(requestId, oldOwnerId);
581
+ await this.clearOwnRequest(requestId);
359
582
 
360
583
  if (action === "stop") {
361
584
  const after = await this.status();
362
585
  return this.result(action, true, "stopped telegram daemon", before, after, warnings);
363
586
  }
364
587
 
365
- const spawned = await spawnTelegramDaemonOwner(
366
- { settings: this.settings, roots, tokenFingerprint: fp, chatId },
367
- this.spawnDeps(),
368
- );
588
+ const { spawned, ready } = await this.spawnAndWait(roots, fp, chatId);
369
589
  warnings.push(...spawned.warnings);
370
590
  const after = await this.status();
371
591
  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");
592
+ const attachedState = await readDaemonState(this.settings, this.fsImpl);
593
+ if (
594
+ !isCurrentCompatibleOwner({
595
+ state: attachedState,
596
+ now: this.now(),
597
+ tokenFingerprint: fp,
598
+ chatId,
599
+ pidAlive: this.pidAlive,
600
+ pidIncarnation: this.deps.pidIncarnation,
601
+ })
602
+ ) {
603
+ return this.result(
604
+ action,
605
+ false,
606
+ "ownership moved to a non-ready or incompatible daemon; reload required",
607
+ before,
608
+ after,
609
+ warnings,
610
+ );
611
+ }
612
+ warnings.push("a ready current-compatible owner already exists; attached instead of spawning");
374
613
  } else if (after.ownerId && after.ownerId === oldOwnerId) {
375
614
  warnings.push("owner id unchanged after reload");
376
615
  }
@@ -387,20 +626,17 @@ export class TelegramDaemonController implements BuiltInDaemonController {
387
626
  }
388
627
  return this.result(
389
628
  action,
390
- spawned.result !== "disabled",
391
- `reloaded telegram daemon (${spawned.result})`,
629
+ (spawned.result === "owner_spawned" || spawned.result === "attached") && ready && after.health === "running",
630
+ ready ? `reloaded telegram daemon (${spawned.result})` : "telegram daemon did not become ready after spawning",
392
631
  before,
393
632
  after,
394
633
  warnings,
395
634
  );
396
635
  }
397
636
 
398
- /** Clear our own control request unless a newer owner-scoped request replaced it. */
399
- private async clearOwnRequest(requestId: string, oldOwnerId: string): Promise<void> {
637
+ /** Clear only our exact control request; a successor request must survive. */
638
+ private async clearOwnRequest(requestId: string): Promise<void> {
400
639
  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
- }
640
+ if (current?.requestId === requestId) await clearTelegramControlRequest(this.settings, requestId, this.fsImpl);
405
641
  }
406
642
  }