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