@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
@@ -1,7 +1,10 @@
1
1
  import { spawn as childProcessSpawn } from "node:child_process";
2
2
  import * as crypto from "node:crypto";
3
3
  import * as fs from "node:fs";
4
+ import * as os from "node:os";
4
5
  import * as path from "node:path";
6
+ import * as native from "@gajae-code/natives";
7
+ import { Process } from "@gajae-code/natives";
5
8
  import type { Settings } from "../../config/settings";
6
9
  import type {
7
10
  BuiltInDaemonController,
@@ -18,6 +21,23 @@ import { getNotificationConfig, isDiscordConfigured, isSlackConfigured } from ".
18
21
  export type ChatDaemonKind = "discord" | "slack";
19
22
  export type ChatDaemonAction = "stop" | "reload";
20
23
 
24
+ /**
25
+ * Operational generations of the Discord/Slack daemon lifecycle contracts.
26
+ * These are intentionally separate from per-session endpoint generations.
27
+ * Generation 6 carries the retained managed filesystem authority boundary.
28
+ * Generation 7 restores macOS daemon signaling (kill(2) with a start-time
29
+ * incarnation recheck) so a live/hung owner can be replaced without an external
30
+ * `kill -9`.
31
+ */
32
+ export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
33
+ discord: 7,
34
+ slack: 7,
35
+ };
36
+
37
+ export function chatDaemonGeneration(kind: ChatDaemonKind): number {
38
+ return CHAT_DAEMON_GENERATIONS[kind];
39
+ }
40
+
21
41
  export interface ChatDaemonState {
22
42
  version: 1;
23
43
  kind: ChatDaemonKind;
@@ -28,9 +48,111 @@ export interface ChatDaemonState {
28
48
  startedAt: number;
29
49
  heartbeatAt: number;
30
50
  transportHealthy: boolean;
51
+ generation: number;
31
52
  stoppedAt?: number;
32
53
  }
33
54
 
55
+ /**
56
+ * State files are untrusted persisted input. A record must be completely valid
57
+ * before its PID can be treated as an owner, stopped, or safe to replace.
58
+ */
59
+ /** A legacy owner is recognized only when the sole missing field is generation. */
60
+ export function isRecognizedLegacyGeneration(value: unknown): value is undefined {
61
+ return value === undefined;
62
+ }
63
+
64
+ function hasProcessIncarnationAuthority(incarnation: unknown): incarnation is string {
65
+ return typeof incarnation === "string" && isProcessIncarnation(incarnation);
66
+ }
67
+
68
+ function hasSafeChatDaemonOwnerShape(
69
+ value: unknown,
70
+ ): value is Omit<ChatDaemonState, "generation"> & { generation?: unknown } {
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
72
+ const state = value as Record<string, unknown>;
73
+ return (
74
+ state.version === 1 &&
75
+ (state.kind === "discord" || state.kind === "slack") &&
76
+ typeof state.pid === "number" &&
77
+ Number.isSafeInteger(state.pid) &&
78
+ state.pid > 0 &&
79
+ typeof state.ownerId === "string" &&
80
+ state.ownerId.length > 0 &&
81
+ typeof state.identity === "string" &&
82
+ state.identity.length > 0 &&
83
+ hasProcessIncarnationAuthority(state.incarnation) &&
84
+ typeof state.startedAt === "number" &&
85
+ Number.isFinite(state.startedAt) &&
86
+ typeof state.heartbeatAt === "number" &&
87
+ Number.isFinite(state.heartbeatAt) &&
88
+ typeof state.transportHealthy === "boolean" &&
89
+ (state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
90
+ );
91
+ }
92
+
93
+ export function hasSafeChatDaemonStateShape(value: unknown): value is ChatDaemonState {
94
+ if (!hasSafeChatDaemonOwnerShape(value)) return false;
95
+ const state = value as Record<string, unknown>;
96
+ return typeof state.generation === "number" && Number.isSafeInteger(state.generation) && state.generation >= 0;
97
+ }
98
+
99
+ /**
100
+ * Versions before immutable process provenance persisted this single sentinel.
101
+ * Recover it only after proving its recorded PID is dead; every other malformed
102
+ * record remains fail-closed.
103
+ */
104
+ function isExactPreUpgradeUnavailableChatDaemonState(
105
+ value: unknown,
106
+ ): value is Omit<ChatDaemonState, "generation" | "incarnation"> & { incarnation: "unavailable" } {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
108
+ const state = value as Record<string, unknown>;
109
+ const keys = Object.keys(state);
110
+ if (
111
+ keys.some(
112
+ key =>
113
+ key !== "version" &&
114
+ key !== "kind" &&
115
+ key !== "pid" &&
116
+ key !== "ownerId" &&
117
+ key !== "identity" &&
118
+ key !== "incarnation" &&
119
+ key !== "startedAt" &&
120
+ key !== "heartbeatAt" &&
121
+ key !== "transportHealthy" &&
122
+ key !== "stoppedAt",
123
+ )
124
+ )
125
+ return false;
126
+ return (
127
+ state.version === 1 &&
128
+ (state.kind === "discord" || state.kind === "slack") &&
129
+ typeof state.pid === "number" &&
130
+ Number.isSafeInteger(state.pid) &&
131
+ state.pid > 0 &&
132
+ typeof state.ownerId === "string" &&
133
+ state.ownerId.length > 0 &&
134
+ typeof state.identity === "string" &&
135
+ state.identity.length > 0 &&
136
+ state.incarnation === "unavailable" &&
137
+ typeof state.startedAt === "number" &&
138
+ Number.isFinite(state.startedAt) &&
139
+ typeof state.heartbeatAt === "number" &&
140
+ Number.isFinite(state.heartbeatAt) &&
141
+ typeof state.transportHealthy === "boolean" &&
142
+ (state.stoppedAt === undefined || (typeof state.stoppedAt === "number" && Number.isFinite(state.stoppedAt)))
143
+ );
144
+ }
145
+
146
+ function hasChatDaemonStatePid(value: unknown): value is { pid: number } {
147
+ return (
148
+ !!value &&
149
+ typeof value === "object" &&
150
+ typeof (value as { pid?: unknown }).pid === "number" &&
151
+ Number.isSafeInteger((value as { pid: number }).pid) &&
152
+ (value as { pid: number }).pid > 0
153
+ );
154
+ }
155
+
34
156
  export interface ChatDaemonControlRequest {
35
157
  version: 1;
36
158
  requestId: string;
@@ -41,21 +163,61 @@ export interface ChatDaemonControlRequest {
41
163
  incarnation: string;
42
164
  }
43
165
 
166
+ export interface ChatDaemonProcessReference {
167
+ incarnation: string;
168
+ signalRoot(signal: NodeJS.Signals): void;
169
+ }
170
+
171
+ function defaultProcessReference(pid: number, platform = os.platform()): ChatDaemonProcessReference | undefined {
172
+ try {
173
+ const processRef = Process.fromPid(pid);
174
+ if (!processRef || !hasProcessIncarnationAuthority(processRef.incarnation)) return undefined;
175
+ const incarnation = processRef.incarnation;
176
+ return {
177
+ incarnation,
178
+ signalRoot: signal => {
179
+ const nativeSignal = os.constants.signals[signal];
180
+ if (nativeSignal === undefined) throw new Error(`Unsupported signal: ${signal}`);
181
+ // macOS exposes no pidfd and the native signal_root is a no-op there, so
182
+ // the daemon control plane previously had NO way to signal a live owner —
183
+ // every stop/reload of a live/hung daemon refused, and only an external
184
+ // `kill -9` could recover it. Signal by numeric PID via kill(2), but
185
+ // re-read the immutable start-time incarnation immediately beforehand so a
186
+ // PID that exited and was reused since capture is never signaled.
187
+ if (platform === "darwin") {
188
+ const current = Process.fromPid(pid) as { incarnation?: unknown } | null;
189
+ if (!current || current.incarnation !== incarnation) throw new Error("Pinned process is already gone");
190
+ process.kill(pid, signal);
191
+ return;
192
+ }
193
+ const rootProcess = processRef as typeof processRef & { signalRoot(signal: number): boolean };
194
+ if (!rootProcess.signalRoot(nativeSignal)) throw new Error("Pinned process is already gone");
195
+ },
196
+ };
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
44
202
  export interface ChatDaemonControlDeps {
45
203
  pidAlive?: (pid: number) => boolean;
46
- sendSignal?: (pid: number, signal: NodeJS.Signals) => void;
204
+ processReference?: (pid: number) => ChatDaemonProcessReference | undefined;
47
205
  spawn?: (command: string, args: string[], opts: { detached: boolean; stdio: "ignore" }) => { unref?: () => void };
48
206
  execPath?: string;
49
207
  ownerPid?: number;
50
208
  randomId?: () => string;
51
209
  pidIncarnation?: (pid: number) => string | undefined;
210
+ /** Test seam for platform-specific default stable-process authority. */
211
+ platform?: NodeJS.Platform;
52
212
  sleep?: (ms: number) => Promise<void>;
213
+ spawnReadyTimeoutMs?: number;
53
214
  }
54
215
 
55
216
  const HEARTBEAT_TTL_MS = 20_000;
56
217
  const DEFAULT_GRACEFUL_TIMEOUT_MS = 8_000;
57
218
  const DEFAULT_KILL_TIMEOUT_MS = 3_000;
58
- const UNPUBLISHED_OWNER_LOCK_STALE_MS = HEARTBEAT_TTL_MS;
219
+ /** Covers Discord READY plus its first 5-second heartbeat; tests inject a smaller timeout. */
220
+ const DEFAULT_SPAWN_READY_TIMEOUT_MS = 8_000;
59
221
 
60
222
  interface ChatDaemonOwnerLock {
61
223
  pid: number;
@@ -63,6 +225,15 @@ interface ChatDaemonOwnerLock {
63
225
  createdAt: number;
64
226
  }
65
227
 
228
+ interface ChatDaemonOwnerLockLease {
229
+ content: string;
230
+ dev: bigint;
231
+ ino: bigint;
232
+ size: bigint;
233
+ mtimeNs: bigint;
234
+ sha256: string;
235
+ }
236
+
66
237
  interface ChatDaemonOwnershipProbe {
67
238
  pidAlive(pid: number): boolean;
68
239
  pidIncarnation(pid: number): string | undefined;
@@ -117,12 +288,11 @@ function defaultPidAlive(pid: number): boolean {
117
288
  return false;
118
289
  }
119
290
  }
120
- function defaultSignal(pid: number, signal: NodeJS.Signals): void {
121
- try {
122
- process.kill(pid, signal);
123
- } catch {}
291
+ /** Windows process ownership uses immutable StartTime/FileTime provenance through
292
+ * the shared native/platform authority. Without authority, numeric PIDs are never trusted. */
293
+ function defaultPidIncarnation(pid: number): string | undefined {
294
+ return processIncarnation(pid);
124
295
  }
125
-
126
296
  function runtimeInfo(execPath?: string): DaemonRuntimeInfo {
127
297
  const rt = resolveGjcRuntimeSpawnInfo(execPath ?? process.execPath);
128
298
  return {
@@ -219,6 +389,15 @@ export function buildChatDaemonSpawnArgs(input: {
219
389
  };
220
390
  }
221
391
 
392
+ type ChatDaemonStateClassification =
393
+ | "absent"
394
+ | "replaceable"
395
+ | "compatible"
396
+ | "newer"
397
+ | "malformed"
398
+ | "unauthorized"
399
+ | "stopped";
400
+
222
401
  export class ChatDaemonController implements BuiltInDaemonController {
223
402
  readonly kind: ChatDaemonKind;
224
403
  constructor(
@@ -237,20 +416,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
237
416
  async status(): Promise<DaemonStatus> {
238
417
  const runtime = runtimeInfo(this.deps.execPath);
239
418
  const identity = this.identity();
240
- if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
241
419
  const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
242
- const live = Boolean(
243
- state &&
244
- state.version === 1 &&
245
- state.kind === this.kind &&
246
- state.identity === identity &&
247
- !state.stoppedAt &&
248
- state.transportHealthy &&
249
- Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
250
- this.alive(state.pid) &&
251
- this.incarnation(state.pid) === state.incarnation,
252
- );
253
- const health: DaemonHealth = live ? "running" : state && !state.stoppedAt ? "stale" : "stopped";
420
+ if (!identity) return { kind: this.kind, configured: false, health: "not_configured", runtime };
421
+ const health: DaemonHealth = this.stateHealth(state, identity);
254
422
  return {
255
423
  kind: this.kind,
256
424
  configured: true,
@@ -272,14 +440,27 @@ export class ChatDaemonController implements BuiltInDaemonController {
272
440
  const identity = this.identity();
273
441
  if (!identity) return "disabled";
274
442
  const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
275
- if (existing && this.isLiveState(existing)) {
276
- if (existing.identity === identity) return "attached";
443
+ const classification = this.classify(existing, identity);
444
+ if (classification === "malformed" || classification === "unauthorized")
445
+ throw new Error(`Unable to replace unauthorized ${this.kind} daemon owner`);
446
+ if (existing && this.isSignalableMatchingOwner(existing)) {
447
+ if (classification === "compatible" || classification === "newer") {
448
+ // A compatible, physically-live owner may be mid-startup: a concurrent
449
+ // ensure can have just acquired ownership and published transportHealthy:false
450
+ // before its transport heartbeats healthy. Wait bounded for that owner to
451
+ // become attachable instead of failing a racing startup outright.
452
+ if (this.isHealthyFreshState(existing) || (await this.waitForOwnership(existing.ownerId, identity)))
453
+ return "attached";
454
+ if (classification === "newer")
455
+ throw new Error(`Unable to replace newer ${this.kind} daemon owner; upgrade this controller`);
456
+ throw new Error(`Unable to replace unhealthy ${this.kind} daemon owner`);
457
+ }
277
458
  await this.stopForReplacement(existing);
278
459
  }
279
460
  const spawned = await this.spawn();
280
461
  if (spawned) return "owner_spawned";
281
462
  const replacement = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
282
- if (replacement && replacement.identity === identity && this.isLiveState(replacement)) return "attached";
463
+ if (replacement && this.isCurrentCompatibleState(replacement, identity)) return "attached";
283
464
  throw new Error(`Unable to attach or spawn ${this.kind} daemon owner`);
284
465
  }
285
466
 
@@ -288,24 +469,54 @@ export class ChatDaemonController implements BuiltInDaemonController {
288
469
  const warnings = before.runtime.warning ? [before.runtime.warning] : [];
289
470
  if (!before.configured)
290
471
  return this.result(action, false, `${this.kind} notifications are not configured`, before, before, warnings);
291
- if (before.health !== "running") {
292
- if (action === "stop")
472
+ const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
473
+ const classification = this.classify(state, this.identity());
474
+ if (classification === "newer")
475
+ return this.result(
476
+ action,
477
+ false,
478
+ `${this.kind} daemon is newer than this controller; upgrade this controller before ${action}`,
479
+ before,
480
+ before,
481
+ warnings,
482
+ );
483
+ if (classification === "malformed" || classification === "unauthorized")
484
+ return this.result(
485
+ action,
486
+ false,
487
+ `${this.kind} daemon ownership changed; refusing to signal`,
488
+ before,
489
+ await this.status(),
490
+ warnings,
491
+ );
492
+ if (!state || !this.isSignalableMatchingOwner(state)) {
493
+ if (action === "stop") {
494
+ if (state && this.isAmbiguouslyLiveState(state))
495
+ return this.result(
496
+ action,
497
+ false,
498
+ `${this.kind} daemon ownership changed; refusing to signal`,
499
+ before,
500
+ before,
501
+ warnings,
502
+ );
293
503
  return this.result(action, true, `no running ${this.kind} daemon`, before, before, warnings);
504
+ }
294
505
  if (opts.spawnIfStopped === false)
295
506
  return this.result(action, true, `no running ${this.kind} daemon to reload`, before, before, warnings);
296
507
  const spawned = await this.spawn();
297
- const after = await this.status();
298
508
  return this.result(
299
509
  action,
300
510
  spawned,
301
- spawned ? `spawned fresh ${this.kind} daemon` : `a live ${this.kind} owner already exists`,
511
+ spawned
512
+ ? `spawned fresh ${this.kind} daemon`
513
+ : `${this.kind} daemon did not publish ownership after spawning`,
302
514
  before,
303
- after,
515
+ await this.status(),
304
516
  warnings,
305
517
  );
306
518
  }
307
- const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
308
- if (!state || !this.ownsCapturedState(state, before))
519
+ if (state.identity !== this.identity() || !this.ownsCapturedState(state, before))
309
520
  return this.result(
310
521
  action,
311
522
  false,
@@ -359,20 +570,75 @@ export class ChatDaemonController implements BuiltInDaemonController {
359
570
  private incarnation(pid: number): string | undefined {
360
571
  return (this.deps.pidIncarnation ?? processIncarnation)(pid);
361
572
  }
362
- private isLiveState(state: ChatDaemonState): boolean {
573
+ private processReference(pid: number): ChatDaemonProcessReference | undefined {
574
+ return this.deps.processReference
575
+ ? this.deps.processReference(pid)
576
+ : defaultProcessReference(pid, this.deps.platform);
577
+ }
578
+ private isDefinitelyStoppedState(state: ChatDaemonState | undefined): boolean {
579
+ if (isExactPreUpgradeUnavailableChatDaemonState(state)) return !this.alive(state.pid);
580
+ if (!state || !hasSafeChatDaemonOwnerShape(state)) return false;
581
+ if (!this.alive(state.pid)) return true;
582
+ // Proven PID reuse means this persisted owner is gone. The distinct live PID
583
+ // remains nonsignalable because isSignalableMatchingOwner requires equality.
584
+ const incarnation = this.incarnation(state.pid);
585
+ return hasProcessIncarnationAuthority(incarnation) && incarnation !== state.incarnation;
586
+ }
587
+ private stateHealth(state: ChatDaemonState | undefined, identity: string): DaemonHealth {
588
+ if (!state || this.isDefinitelyStoppedState(state)) return "stopped";
589
+ if (this.isCurrentCompatibleState(state, identity)) return "running";
590
+ // A PID that is live but cannot prove a matching current incarnation is
591
+ // ambiguous: do not report it ready or overwrite it.
592
+ return "stale";
593
+ }
594
+ private isSignalableMatchingOwner(state: ChatDaemonState): boolean {
595
+ const incarnation = this.incarnation(state.pid);
363
596
  return (
364
- state.version === 1 &&
597
+ hasSafeChatDaemonOwnerShape(state) &&
365
598
  state.kind === this.kind &&
366
- !state.stoppedAt &&
367
- state.transportHealthy &&
368
- Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS &&
599
+ state.stoppedAt === undefined &&
369
600
  this.alive(state.pid) &&
370
- Boolean(state.incarnation) &&
371
- this.incarnation(state.pid) === state.incarnation
601
+ hasProcessIncarnationAuthority(incarnation) &&
602
+ incarnation === state.incarnation
372
603
  );
373
604
  }
605
+ /** A live PID with an invalid ownership record is never safe to overwrite. */
606
+ private isAmbiguouslyLiveState(state: ChatDaemonState): boolean {
607
+ return !this.isDefinitelyStoppedState(state) && !this.isSignalableMatchingOwner(state);
608
+ }
609
+
610
+ private isHealthyFreshState(state: ChatDaemonState): boolean {
611
+ return (
612
+ hasSafeChatDaemonStateShape(state) &&
613
+ state.transportHealthy &&
614
+ Date.now() - state.heartbeatAt <= HEARTBEAT_TTL_MS
615
+ );
616
+ }
617
+ private classify(state: ChatDaemonState | undefined, identity: string | undefined): ChatDaemonStateClassification {
618
+ if (!state) return "absent";
619
+ if (isExactPreUpgradeUnavailableChatDaemonState(state)) return this.alive(state.pid) ? "malformed" : "stopped";
620
+ if (!hasSafeChatDaemonOwnerShape(state)) return "malformed";
621
+ if (this.isDefinitelyStoppedState(state)) return "stopped";
622
+ if (!identity || state.kind !== this.kind || state.identity !== identity) return "unauthorized";
623
+ if (hasSafeChatDaemonStateShape(state)) {
624
+ if (state.generation < chatDaemonGeneration(this.kind)) return "replaceable";
625
+ return state.generation > chatDaemonGeneration(this.kind) ? "newer" : "compatible";
626
+ }
627
+ const generation = (state as { generation?: unknown }).generation;
628
+ return isRecognizedLegacyGeneration(generation) ? "replaceable" : "malformed";
629
+ }
630
+ private isCurrentCompatibleState(state: ChatDaemonState, identity: string): boolean {
631
+ const classification = this.classify(state, identity);
632
+ return (
633
+ this.isSignalableMatchingOwner(state) &&
634
+ this.isHealthyFreshState(state) &&
635
+ (classification === "compatible" || classification === "newer")
636
+ );
637
+ }
638
+
374
639
  private async stopForReplacement(state: ChatDaemonState): Promise<void> {
375
- if (!this.isLiveState(state)) return;
640
+ if (!this.isSignalableMatchingOwner(state)) return;
641
+
376
642
  const requestId = this.deps.randomId?.() ?? crypto.randomUUID();
377
643
  await writeChatDaemonControlRequest(this.settings.getAgentDir(), this.kind, {
378
644
  version: 1,
@@ -403,21 +669,35 @@ export class ChatDaemonController implements BuiltInDaemonController {
403
669
  state.ownerId === before.ownerId &&
404
670
  state.pid === before.pid &&
405
671
  Boolean(state.incarnation) &&
406
- this.incarnation(state.pid) === state.incarnation
672
+ this.isSignalableMatchingOwner(state)
407
673
  );
408
674
  }
409
675
  private async signalIfOwner(state: ChatDaemonState, signal: NodeJS.Signals): Promise<boolean> {
410
676
  const current = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
677
+ const identity = this.identity();
678
+ const classification = this.classify(current, identity);
411
679
  if (
680
+ !identity ||
412
681
  !current ||
413
682
  current.ownerId !== state.ownerId ||
414
683
  current.pid !== state.pid ||
684
+ current.identity !== state.identity ||
415
685
  current.incarnation !== state.incarnation ||
416
- this.incarnation(state.pid) !== state.incarnation
686
+ current.generation !== state.generation ||
687
+ (classification !== "compatible" && classification !== "replaceable") ||
688
+ !this.isSignalableMatchingOwner(current)
417
689
  )
418
690
  return false;
419
- (this.deps.sendSignal ?? defaultSignal)(state.pid, signal);
420
- return true;
691
+ const processRef = this.processReference(state.pid);
692
+ // Numeric PIDs can be reused after the ordinary provenance recheck. Only the
693
+ // native stable reference may perform this privileged signal operation.
694
+ if (!processRef || processRef.incarnation !== state.incarnation) return false;
695
+ try {
696
+ processRef.signalRoot(signal);
697
+ return true;
698
+ } catch {
699
+ return false;
700
+ }
421
701
  }
422
702
  private async ownerChanged(
423
703
  action: ChatDaemonAction,
@@ -459,15 +739,10 @@ export class ChatDaemonController implements BuiltInDaemonController {
459
739
  const paths = chatDaemonPaths(this.settings.getAgentDir(), this.kind);
460
740
  await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
461
741
  const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
462
- if (
463
- existing &&
464
- !existing.stoppedAt &&
465
- existing.identity === identity &&
466
- Date.now() - existing.heartbeatAt <= HEARTBEAT_TTL_MS &&
467
- this.alive(existing.pid) &&
468
- this.incarnation(existing.pid) === existing.incarnation
469
- )
470
- return false;
742
+ const classification = this.classify(existing, identity);
743
+ if (classification === "malformed" || classification === "unauthorized") return false;
744
+ if (existing && (this.isSignalableMatchingOwner(existing) || this.isAmbiguouslyLiveState(existing))) return false;
745
+
471
746
  const ownerId = `${this.deps.ownerPid ?? process.ppid}-${this.deps.randomId?.() ?? crypto.randomUUID()}`;
472
747
  const { command, args } = buildChatDaemonSpawnArgs({
473
748
  kind: this.kind,
@@ -479,7 +754,26 @@ export class ChatDaemonController implements BuiltInDaemonController {
479
754
  detached: true,
480
755
  stdio: "ignore",
481
756
  }).unref?.();
482
- return true;
757
+ return await this.waitForOwnership(ownerId, identity);
758
+ }
759
+ private async waitForOwnership(ownerId: string, identity: string): Promise<boolean> {
760
+ const timeoutMs = Math.max(this.deps.spawnReadyTimeoutMs ?? DEFAULT_SPAWN_READY_TIMEOUT_MS, 0);
761
+ const until = Date.now() + timeoutMs;
762
+ const maxPolls = Math.ceil(timeoutMs / 25);
763
+ for (let poll = 0; poll <= maxPolls; poll++) {
764
+ const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
765
+ const classification = state ? this.classify(state, identity) : undefined;
766
+ if (
767
+ state &&
768
+ state.ownerId === ownerId &&
769
+ (classification === "compatible" || classification === "newer") &&
770
+ this.isCurrentCompatibleState(state, identity)
771
+ )
772
+ return true;
773
+ if (Date.now() >= until || poll === maxPolls) return false;
774
+ await this.sleep(25);
775
+ }
776
+ return false;
483
777
  }
484
778
  }
485
779
 
@@ -523,44 +817,132 @@ export async function acquireChatDaemonOwnership(input: {
523
817
  pidAlive: input.pidAlive ?? defaultPidAlive,
524
818
  pidIncarnation: input.pidIncarnation ?? processIncarnation,
525
819
  };
526
- const incarnation = input.incarnation ?? probe.pidIncarnation(pid) ?? "unavailable";
820
+ const incarnation = input.incarnation ?? probe.pidIncarnation(pid);
821
+ if (!hasProcessIncarnationAuthority(incarnation)) return false;
822
+
527
823
  await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
528
- if (!(await createChatDaemonOwnerLock(paths.lock, { pid, incarnation, createdAt: Date.now() }))) {
824
+ const existing = await readJson<unknown>(paths.state);
825
+ // A live record remains fenced unless authoritative provenance proves the PID
826
+ // was reused. This permits recovery without ever signaling that replacement.
827
+ if (hasChatDaemonStatePid(existing) && probe.pidAlive(existing.pid)) {
828
+ const current = probe.pidIncarnation(existing.pid);
829
+ if (
830
+ !hasSafeChatDaemonOwnerShape(existing) ||
831
+ !hasProcessIncarnationAuthority(current) ||
832
+ current === existing.incarnation
833
+ )
834
+ return false;
835
+ }
836
+ const owner = { pid, incarnation, createdAt: Date.now() };
837
+ let lock = await createChatDaemonOwnerLock(paths.lock, owner);
838
+ if (!lock) {
529
839
  if (!(await reclaimChatDaemonOwnerLock(paths.lock, paths.state, probe))) return false;
530
- if (!(await createChatDaemonOwnerLock(paths.lock, { pid, incarnation, createdAt: Date.now() }))) return false;
531
- }
532
-
533
- await withStateWriteLock(
534
- paths.state,
535
- async () =>
536
- await writeJson(paths.state, {
537
- version: 1,
538
- kind: input.kind,
539
- pid,
540
- ownerId: input.ownerId,
541
- identity: input.identity,
542
- incarnation,
543
- startedAt: Date.now(),
544
- heartbeatAt: Date.now(),
545
- transportHealthy: false,
546
- } satisfies ChatDaemonState),
547
- );
548
- return true;
840
+ lock = await createChatDaemonOwnerLock(paths.lock, owner);
841
+ if (!lock) return false;
842
+ }
843
+ return await withStateWriteLock(paths.state, async () => {
844
+ if (!(await ownsChatDaemonOwnerLock(paths.lock, lock))) return false;
845
+ await writeJson(paths.state, {
846
+ version: 1,
847
+ kind: input.kind,
848
+ pid,
849
+ ownerId: input.ownerId,
850
+ identity: input.identity,
851
+ incarnation,
852
+ startedAt: Date.now(),
853
+ heartbeatAt: Date.now(),
854
+ transportHealthy: false,
855
+ generation: chatDaemonGeneration(input.kind),
856
+ } satisfies ChatDaemonState);
857
+ return true;
858
+ });
549
859
  }
550
860
 
551
- async function createChatDaemonOwnerLock(lock: string, owner: ChatDaemonOwnerLock): Promise<boolean> {
861
+ async function createChatDaemonOwnerLock(
862
+ lock: string,
863
+ owner: ChatDaemonOwnerLock,
864
+ ): Promise<ChatDaemonOwnerLockLease | undefined> {
865
+ const content = `${JSON.stringify(owner)}\n`;
866
+ const temporary = `${lock}.${process.pid}.${crypto.randomUUID()}.tmp`;
552
867
  try {
553
- const handle = await fs.promises.open(lock, "wx", 0o600);
868
+ const handle = await fs.promises.open(temporary, "wx", 0o600);
554
869
  try {
555
- await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
870
+ await handle.writeFile(content, "utf8");
556
871
  await handle.sync();
557
872
  } finally {
558
873
  await handle.close();
559
874
  }
560
- return true;
561
- } catch (error) {
562
- if (isAlreadyExists(error)) return false;
563
- throw error;
875
+ try {
876
+ await fs.promises.link(temporary, lock);
877
+ } catch (error) {
878
+ if (isAlreadyExists(error)) return undefined;
879
+ throw error;
880
+ }
881
+ return await captureChatDaemonOwnerLockLease(lock);
882
+ } finally {
883
+ await fs.promises.unlink(temporary).catch(() => undefined);
884
+ }
885
+ }
886
+
887
+ async function captureChatDaemonOwnerLockLease(lock: string): Promise<ChatDaemonOwnerLockLease | undefined> {
888
+ try {
889
+ const handle = await fs.promises.open(lock, "r");
890
+ try {
891
+ const before = await handle.stat({ bigint: true });
892
+ if (!before.isFile()) return undefined;
893
+ const content = await handle.readFile({ encoding: "utf8" });
894
+ const after = await handle.stat({ bigint: true });
895
+ const pathname = await fs.promises.lstat(lock, { bigint: true });
896
+ if (
897
+ !after.isFile() ||
898
+ !pathname.isFile() ||
899
+ pathname.isSymbolicLink() ||
900
+ before.dev !== after.dev ||
901
+ before.ino !== after.ino ||
902
+ before.size !== after.size ||
903
+ before.mtimeNs !== after.mtimeNs ||
904
+ before.dev !== pathname.dev ||
905
+ before.ino !== pathname.ino ||
906
+ before.size !== pathname.size ||
907
+ before.mtimeNs !== pathname.mtimeNs
908
+ )
909
+ return undefined;
910
+ return {
911
+ content,
912
+ dev: before.dev,
913
+ ino: before.ino,
914
+ size: before.size,
915
+ mtimeNs: before.mtimeNs,
916
+ sha256: crypto.createHash("sha256").update(content).digest("hex"),
917
+ };
918
+ } finally {
919
+ await handle.close();
920
+ }
921
+ } catch {
922
+ return undefined;
923
+ }
924
+ }
925
+
926
+ async function ownsChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): Promise<boolean> {
927
+ const current = await captureChatDaemonOwnerLockLease(lock);
928
+ return (
929
+ current?.dev === lease.dev &&
930
+ current.ino === lease.ino &&
931
+ current.size === lease.size &&
932
+ current.mtimeNs === lease.mtimeNs &&
933
+ current.content === lease.content
934
+ );
935
+ }
936
+
937
+ /** Deletes only the exact lease observed by this contender; a successor is retained. */
938
+ function unlinkExactChatDaemonOwnerLock(lock: string, lease: ChatDaemonOwnerLockLease): boolean {
939
+ try {
940
+ return native.exactUnlink(lock, {
941
+ ...lease,
942
+ quarantineName: `.gjc-delete-chat-daemon-lock-${crypto.randomUUID()}`,
943
+ }).ok;
944
+ } catch {
945
+ return false;
564
946
  }
565
947
  }
566
948
 
@@ -570,64 +952,68 @@ async function reclaimChatDaemonOwnerLock(
570
952
  probe: ChatDaemonOwnershipProbe,
571
953
  ): Promise<boolean> {
572
954
  if (!(await canReclaimChatDaemonOwnerLock(lock, stateFile, probe))) return false;
573
-
574
955
  const reclaimFile = `${lock}.reclaim`;
575
956
  const reclaimLock = await acquireChatDaemonReclaimLock(reclaimFile, probe);
576
-
577
957
  if (!reclaimLock) return false;
578
958
  try {
579
- if (!(await canReclaimChatDaemonOwnerLock(lock, stateFile, probe))) return false;
580
-
581
- await fs.promises.unlink(lock).catch(() => undefined);
582
- return true;
959
+ const ownerLock = await canReclaimChatDaemonOwnerLock(lock, stateFile, probe);
960
+ return !!ownerLock && unlinkExactChatDaemonOwnerLock(lock, ownerLock);
583
961
  } finally {
584
- await reclaimLock.close().catch(() => undefined);
585
- await fs.promises.unlink(reclaimFile).catch(() => undefined);
962
+ unlinkExactChatDaemonOwnerLock(reclaimFile, reclaimLock);
586
963
  }
587
964
  }
588
965
 
589
966
  async function acquireChatDaemonReclaimLock(
590
967
  reclaimFile: string,
591
968
  probe: ChatDaemonOwnershipProbe,
592
- ): Promise<{ close(): Promise<void> } | undefined> {
593
- const owner: ChatDaemonOwnerLock = {
594
- pid: process.pid,
595
- incarnation: probe.pidIncarnation(process.pid) ?? "unavailable",
596
- createdAt: Date.now(),
597
- };
598
- if (await createChatDaemonOwnerLock(reclaimFile, owner)) return await fs.promises.open(reclaimFile, "r+");
599
- if (!(await isStaleChatDaemonLock(reclaimFile, probe))) return undefined;
600
- await fs.promises.unlink(reclaimFile).catch(() => undefined);
601
- if (!(await createChatDaemonOwnerLock(reclaimFile, owner))) return undefined;
602
- return await fs.promises.open(reclaimFile, "r+");
969
+ ): Promise<ChatDaemonOwnerLockLease | undefined> {
970
+ const incarnation = probe.pidIncarnation(process.pid);
971
+ if (!hasProcessIncarnationAuthority(incarnation)) return undefined;
972
+ const owner: ChatDaemonOwnerLock = { pid: process.pid, incarnation, createdAt: Date.now() };
973
+ const created = await createChatDaemonOwnerLock(reclaimFile, owner);
974
+ if (created) return created;
975
+ const stale = await staleChatDaemonLockLease(reclaimFile, probe);
976
+ if (!stale || !unlinkExactChatDaemonOwnerLock(reclaimFile, stale)) return undefined;
977
+ return await createChatDaemonOwnerLock(reclaimFile, owner);
603
978
  }
604
979
 
605
- async function isStaleChatDaemonLock(lock: string, probe: ChatDaemonOwnershipProbe): Promise<boolean> {
980
+ async function staleChatDaemonLockLease(
981
+ lock: string,
982
+ probe: ChatDaemonOwnershipProbe,
983
+ ): Promise<ChatDaemonOwnerLockLease | undefined> {
984
+ const lease = await captureChatDaemonOwnerLockLease(lock);
985
+ if (!lease) return undefined;
606
986
  let owner: unknown;
607
987
  try {
608
- owner = JSON.parse(await fs.promises.readFile(lock, "utf8"));
609
- } catch {}
610
- if (isChatDaemonOwnerLock(owner)) {
611
- const currentIncarnation = probe.pidIncarnation(owner.pid);
612
- return (
613
- !probe.pidAlive(owner.pid) ||
614
- (owner.incarnation !== "unavailable" &&
615
- (!isProcessIncarnation(owner.incarnation) ||
616
- (currentIncarnation !== undefined && currentIncarnation !== owner.incarnation)))
617
- );
988
+ owner = JSON.parse(lease.content);
989
+ } catch {
990
+ return undefined;
618
991
  }
619
- const stat = await fs.promises.stat(lock).catch(() => undefined);
620
- return Boolean(stat && Date.now() - stat.mtimeMs >= UNPUBLISHED_OWNER_LOCK_STALE_MS);
992
+ if (!isChatDaemonOwnerLock(owner)) return undefined;
993
+ if (!probe.pidAlive(owner.pid)) return lease;
994
+ if (!hasProcessIncarnationAuthority(owner.incarnation)) return undefined;
995
+ const currentIncarnation = probe.pidIncarnation(owner.pid);
996
+ return hasProcessIncarnationAuthority(currentIncarnation) && currentIncarnation !== owner.incarnation
997
+ ? lease
998
+ : undefined;
621
999
  }
622
1000
 
623
1001
  async function canReclaimChatDaemonOwnerLock(
624
1002
  lock: string,
625
1003
  stateFile: string,
626
1004
  probe: ChatDaemonOwnershipProbe,
627
- ): Promise<boolean> {
628
- const state = await readJson<ChatDaemonState>(stateFile);
629
- if (state && probe.pidAlive(state.pid) && probe.pidIncarnation(state.pid) === state.incarnation) return false;
630
- return await isStaleChatDaemonLock(lock, probe);
1005
+ ): Promise<ChatDaemonOwnerLockLease | undefined> {
1006
+ const state = await readJson<unknown>(stateFile);
1007
+ if (hasChatDaemonStatePid(state) && probe.pidAlive(state.pid)) {
1008
+ const current = probe.pidIncarnation(state.pid);
1009
+ if (
1010
+ !hasSafeChatDaemonOwnerShape(state) ||
1011
+ !hasProcessIncarnationAuthority(current) ||
1012
+ current === state.incarnation
1013
+ )
1014
+ return undefined;
1015
+ }
1016
+ return await staleChatDaemonLockLease(lock, probe);
631
1017
  }
632
1018
 
633
1019
  function isChatDaemonOwnerLock(value: unknown): value is ChatDaemonOwnerLock {
@@ -652,17 +1038,25 @@ export async function renewChatDaemonHeartbeat(input: {
652
1038
  pid?: number;
653
1039
  incarnation?: string;
654
1040
  transportHealthy: boolean;
1041
+ pidAlive?: (pid: number) => boolean;
1042
+ pidIncarnation?: (pid: number) => string | undefined;
655
1043
  }): Promise<boolean> {
656
1044
  const paths = chatDaemonPaths(input.agentDir, input.kind);
1045
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1046
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
657
1047
  return await withStateWriteLock(paths.state, async () => {
658
- const state = await readJson<ChatDaemonState>(paths.state);
659
- const pid = input.pid ?? state?.pid;
1048
+ const state = await readJson<unknown>(paths.state);
1049
+ if (!hasSafeChatDaemonStateShape(state)) return false;
1050
+ const pid = input.pid ?? state.pid;
1051
+ const currentIncarnation = pidIncarnation(pid);
660
1052
  if (
661
- !state ||
662
1053
  state.ownerId !== input.ownerId ||
663
1054
  pid !== state.pid ||
664
- !input.incarnation ||
665
- state.incarnation !== input.incarnation
1055
+ !hasProcessIncarnationAuthority(input.incarnation) ||
1056
+ state.incarnation !== input.incarnation ||
1057
+ !pidAlive(pid) ||
1058
+ !hasProcessIncarnationAuthority(currentIncarnation) ||
1059
+ currentIncarnation !== input.incarnation
666
1060
  )
667
1061
  return false;
668
1062
  await writeJson(paths.state, { ...state, heartbeatAt: Date.now(), transportHealthy: input.transportHealthy });
@@ -673,21 +1067,35 @@ export async function releaseChatDaemonOwnership(input: {
673
1067
  agentDir: string;
674
1068
  kind: ChatDaemonKind;
675
1069
  ownerId: string;
676
- pid?: number;
677
- incarnation?: string;
1070
+ pid: number;
1071
+ incarnation: string;
1072
+ pidAlive?: (pid: number) => boolean;
1073
+ pidIncarnation?: (pid: number) => string | undefined;
678
1074
  }): Promise<void> {
679
1075
  const paths = chatDaemonPaths(input.agentDir, input.kind);
1076
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
1077
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
680
1078
  await withStateWriteLock(paths.state, async () => {
681
- const state = await readJson<ChatDaemonState>(paths.state);
1079
+ const state = await readJson<unknown>(paths.state);
1080
+ const currentIncarnation = pidIncarnation(input.pid);
682
1081
  if (
683
- state?.ownerId !== input.ownerId ||
684
- (input.pid !== undefined && state.pid !== input.pid) ||
685
- (input.incarnation !== undefined && state.incarnation !== input.incarnation)
1082
+ !hasSafeChatDaemonStateShape(state) ||
1083
+ !hasProcessIncarnationAuthority(input.incarnation) ||
1084
+ state.ownerId !== input.ownerId ||
1085
+ state.pid !== input.pid ||
1086
+ state.incarnation !== input.incarnation ||
1087
+ !pidAlive(input.pid) ||
1088
+ !hasProcessIncarnationAuthority(currentIncarnation) ||
1089
+ currentIncarnation !== input.incarnation
686
1090
  )
687
1091
  return;
688
1092
  await writeJson(paths.state, { ...state, stoppedAt: Date.now(), transportHealthy: false });
689
- const lock = await readJson<ChatDaemonOwnerLock>(paths.lock);
690
- if (lock?.pid === state.pid && lock.incarnation === state.incarnation)
691
- await fs.promises.unlink(paths.lock).catch(() => undefined);
1093
+ const lock = await captureChatDaemonOwnerLockLease(paths.lock);
1094
+ let owner: unknown;
1095
+ try {
1096
+ owner = lock && JSON.parse(lock.content);
1097
+ } catch {}
1098
+ if (lock && isChatDaemonOwnerLock(owner) && owner.pid === state.pid && owner.incarnation === state.incarnation)
1099
+ unlinkExactChatDaemonOwnerLock(paths.lock, lock);
692
1100
  });
693
1101
  }