agent-working-memory 0.16.1 → 0.18.1

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 (170) hide show
  1. package/README.md +321 -318
  2. package/dist/adapters/common.d.ts +34 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +528 -444
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +19 -0
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/cli/migrate.d.ts.map +1 -1
  10. package/dist/cli/migrate.js +31 -29
  11. package/dist/cli/migrate.js.map +1 -1
  12. package/dist/core/asked-value.d.ts +37 -0
  13. package/dist/core/asked-value.d.ts.map +1 -0
  14. package/dist/core/asked-value.js +77 -0
  15. package/dist/core/asked-value.js.map +1 -0
  16. package/dist/core/conflicts.d.ts +89 -0
  17. package/dist/core/conflicts.d.ts.map +1 -0
  18. package/dist/core/conflicts.js +267 -0
  19. package/dist/core/conflicts.js.map +1 -0
  20. package/dist/core/embeddings.d.ts +14 -2
  21. package/dist/core/embeddings.d.ts.map +1 -1
  22. package/dist/core/embeddings.js +32 -10
  23. package/dist/core/embeddings.js.map +1 -1
  24. package/dist/core/format-recall.d.ts +21 -1
  25. package/dist/core/format-recall.d.ts.map +1 -1
  26. package/dist/core/format-recall.js +60 -5
  27. package/dist/core/format-recall.js.map +1 -1
  28. package/dist/core/ml-worker.d.ts +24 -1
  29. package/dist/core/ml-worker.d.ts.map +1 -1
  30. package/dist/core/ml-worker.js +165 -4
  31. package/dist/core/ml-worker.js.map +1 -1
  32. package/dist/core/model-daemon-client.d.ts +77 -0
  33. package/dist/core/model-daemon-client.d.ts.map +1 -0
  34. package/dist/core/model-daemon-client.js +383 -0
  35. package/dist/core/model-daemon-client.js.map +1 -0
  36. package/dist/core/model-daemon-server.d.ts +71 -0
  37. package/dist/core/model-daemon-server.d.ts.map +1 -0
  38. package/dist/core/model-daemon-server.js +363 -0
  39. package/dist/core/model-daemon-server.js.map +1 -0
  40. package/dist/core/model-daemon.d.ts +137 -0
  41. package/dist/core/model-daemon.d.ts.map +1 -0
  42. package/dist/core/model-daemon.js +231 -0
  43. package/dist/core/model-daemon.js.map +1 -0
  44. package/dist/core/query-expander.d.ts +5 -0
  45. package/dist/core/query-expander.d.ts.map +1 -1
  46. package/dist/core/query-expander.js +3 -2
  47. package/dist/core/query-expander.js.map +1 -1
  48. package/dist/core/recall-config.d.ts +1 -1
  49. package/dist/core/recall-config.d.ts.map +1 -1
  50. package/dist/core/recall-config.js +5 -0
  51. package/dist/core/recall-config.js.map +1 -1
  52. package/dist/core/rerank-window.d.ts +7 -2
  53. package/dist/core/rerank-window.d.ts.map +1 -1
  54. package/dist/core/rerank-window.js +9 -4
  55. package/dist/core/rerank-window.js.map +1 -1
  56. package/dist/core/rerank2.d.ts +52 -2
  57. package/dist/core/rerank2.d.ts.map +1 -1
  58. package/dist/core/rerank2.js +60 -3
  59. package/dist/core/rerank2.js.map +1 -1
  60. package/dist/core/reranker.d.ts +19 -0
  61. package/dist/core/reranker.d.ts.map +1 -1
  62. package/dist/core/reranker.js +76 -12
  63. package/dist/core/reranker.js.map +1 -1
  64. package/dist/core/salience.d.ts +2 -0
  65. package/dist/core/salience.d.ts.map +1 -1
  66. package/dist/core/salience.js +45 -18
  67. package/dist/core/salience.js.map +1 -1
  68. package/dist/core/trust.d.ts +32 -0
  69. package/dist/core/trust.d.ts.map +1 -0
  70. package/dist/core/trust.js +66 -0
  71. package/dist/core/trust.js.map +1 -0
  72. package/dist/core/validity.d.ts +21 -0
  73. package/dist/core/validity.d.ts.map +1 -0
  74. package/dist/core/validity.js +41 -0
  75. package/dist/core/validity.js.map +1 -0
  76. package/dist/core/whoami.d.ts +3 -0
  77. package/dist/core/whoami.d.ts.map +1 -1
  78. package/dist/core/whoami.js +14 -0
  79. package/dist/core/whoami.js.map +1 -1
  80. package/dist/core/write-pipeline.d.ts +48 -0
  81. package/dist/core/write-pipeline.d.ts.map +1 -1
  82. package/dist/core/write-pipeline.js +104 -4
  83. package/dist/core/write-pipeline.js.map +1 -1
  84. package/dist/engine/activation.d.ts.map +1 -1
  85. package/dist/engine/activation.js +27 -4
  86. package/dist/engine/activation.js.map +1 -1
  87. package/dist/engine/consolidation.d.ts.map +1 -1
  88. package/dist/engine/consolidation.js +67 -5
  89. package/dist/engine/consolidation.js.map +1 -1
  90. package/dist/hooks/prime-query.d.ts +57 -0
  91. package/dist/hooks/prime-query.d.ts.map +1 -0
  92. package/dist/hooks/prime-query.js +388 -0
  93. package/dist/hooks/prime-query.js.map +1 -0
  94. package/dist/hooks/prime.d.ts +8 -0
  95. package/dist/hooks/prime.d.ts.map +1 -1
  96. package/dist/hooks/prime.js +21 -2
  97. package/dist/hooks/prime.js.map +1 -1
  98. package/dist/hooks/sidecar.d.ts +7 -0
  99. package/dist/hooks/sidecar.d.ts.map +1 -1
  100. package/dist/hooks/sidecar.js +10 -2
  101. package/dist/hooks/sidecar.js.map +1 -1
  102. package/dist/index.js +11 -3
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp.js +148 -103
  105. package/dist/mcp.js.map +1 -1
  106. package/dist/model-daemon.d.ts +8 -0
  107. package/dist/model-daemon.d.ts.map +1 -0
  108. package/dist/model-daemon.js +45 -0
  109. package/dist/model-daemon.js.map +1 -0
  110. package/dist/storage/factory.d.ts.map +1 -1
  111. package/dist/storage/factory.js +5 -0
  112. package/dist/storage/factory.js.map +1 -1
  113. package/dist/storage/pglite-schema.d.ts.map +1 -1
  114. package/dist/storage/pglite-schema.js +176 -170
  115. package/dist/storage/pglite-schema.js.map +1 -1
  116. package/dist/storage/pglite.d.ts +5 -0
  117. package/dist/storage/pglite.d.ts.map +1 -1
  118. package/dist/storage/pglite.js +165 -153
  119. package/dist/storage/pglite.js.map +1 -1
  120. package/dist/storage/postgres.d.ts +5 -0
  121. package/dist/storage/postgres.d.ts.map +1 -1
  122. package/dist/storage/postgres.js +165 -153
  123. package/dist/storage/postgres.js.map +1 -1
  124. package/dist/storage/sqlite.d.ts +22 -0
  125. package/dist/storage/sqlite.d.ts.map +1 -1
  126. package/dist/storage/sqlite.js +400 -363
  127. package/dist/storage/sqlite.js.map +1 -1
  128. package/dist/types/engram.d.ts +33 -0
  129. package/dist/types/engram.d.ts.map +1 -1
  130. package/dist/types/engram.js.map +1 -1
  131. package/package.json +106 -106
  132. package/plugin/.claude-plugin/plugin.json +1 -1
  133. package/plugin/README.md +1 -1
  134. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  135. package/plugin/skills/awm-memory/SKILL.md +16 -5
  136. package/src/adapters/common.ts +881 -800
  137. package/src/api/routes.ts +20 -0
  138. package/src/cli/migrate.ts +309 -307
  139. package/src/core/asked-value.ts +74 -0
  140. package/src/core/conflicts.ts +280 -0
  141. package/src/core/embeddings.ts +35 -10
  142. package/src/core/format-recall.ts +83 -25
  143. package/src/core/ml-worker.ts +458 -281
  144. package/src/core/model-daemon-client.ts +374 -0
  145. package/src/core/model-daemon-server.ts +344 -0
  146. package/src/core/model-daemon.ts +243 -0
  147. package/src/core/query-expander.ts +127 -126
  148. package/src/core/recall-config.ts +5 -0
  149. package/src/core/rerank-window.ts +164 -159
  150. package/src/core/rerank2.ts +143 -82
  151. package/src/core/reranker.ts +78 -11
  152. package/src/core/salience.ts +45 -18
  153. package/src/core/trust.ts +65 -0
  154. package/src/core/validity.ts +47 -0
  155. package/src/core/whoami.ts +136 -120
  156. package/src/core/write-pipeline.ts +143 -4
  157. package/src/engine/activation.ts +27 -4
  158. package/src/engine/consolidation.ts +950 -887
  159. package/src/hooks/prime-query.ts +375 -0
  160. package/src/hooks/prime.ts +25 -2
  161. package/src/hooks/sidecar.ts +13 -3
  162. package/src/index.ts +255 -248
  163. package/src/mcp.ts +1521 -1475
  164. package/src/model-daemon.ts +48 -0
  165. package/src/storage/factory.ts +25 -19
  166. package/src/storage/pglite-schema.ts +199 -193
  167. package/src/storage/pglite.ts +1513 -1498
  168. package/src/storage/postgres.ts +1613 -1598
  169. package/src/storage/sqlite.ts +2056 -2019
  170. package/src/types/engram.ts +30 -0
@@ -0,0 +1,374 @@
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Client side of the shared model daemon (see model-daemon.ts).
5
+ *
6
+ * Connecting: try the daemon's address. If nothing answers, take the lockfile (O_EXCL,
7
+ * holding this pid) and spawn the daemon detached, through a launcher so that it is not
8
+ * this process's child (see LAUNCHER); every other process starting at the
9
+ * same moment finds the lock taken and just keeps trying to connect. A lock whose pid is
10
+ * dead, or that is older than the start timeout, is stale and is taken over, so a
11
+ * spawner that crashed mid-start blocks nobody. A daemon this client spawned that exits
12
+ * with an error ends the wait at once instead of at the start timeout.
13
+ *
14
+ * Timeouts: a request waits in the daemon's queue for up to AWM_MODEL_DAEMON_QUEUE_MS
15
+ * (10 min) without failing, because a busy daemon is not a broken one; when it gives up
16
+ * it tells the daemon to drop the job. While the daemon reports a step `started` (and
17
+ * until it reports the job `requeued` between chunks), the step must finish within
18
+ * AWM_MODEL_DAEMON_TIMEOUT_MS (2 min); a daemon that does not is stuck, and the
19
+ * connection is dropped.
20
+ *
21
+ * The client never falls back to in-process inference itself; ml-worker.ts decides that.
22
+ */
23
+
24
+ import { connect, type Socket } from 'node:net';
25
+ import { spawn } from 'node:child_process';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync, mkdirSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import {
31
+ PROTOCOL_VERSION, HELLO_MAX_BYTES, MAX_FRAME_BYTES, addressProblem, encodeFrame, envInt, FrameReader,
32
+ modelDaemonPaths, proof, proofMatches, runDirProblem, EXIT_FINGERPRINT_DRIFT, REQUEST_TIMEOUT_MS, WARM_TIMEOUT_MS,
33
+ type DaemonPaths, type Lane, type ModelOp, type ResponseFrame,
34
+ } from './model-daemon.js';
35
+
36
+ /** The daemon ran the request and it failed there. In-process would fail the same way. */
37
+ export class DaemonRemoteError extends Error {}
38
+
39
+ /** The daemon could not be reached or stopped answering. */
40
+ export class DaemonUnavailableError extends Error {
41
+ constructor(message: string, readonly reason: string) { super(message); }
42
+ }
43
+
44
+ export interface DaemonEntry { command: string; args: string[] }
45
+
46
+ export interface ClientOptions {
47
+ fingerprint: string;
48
+ runDir?: string;
49
+ /** How to start the daemon. Defaults to this package's model-daemon entry. */
50
+ entry?: DaemonEntry;
51
+ /** Extra env for a spawned daemon: the client's captured inference config. */
52
+ spawnEnv?: Record<string, string>;
53
+ startTimeoutMs?: number;
54
+ requestTimeoutMs?: number;
55
+ queueTimeoutMs?: number;
56
+ }
57
+
58
+ const LOG_MAX_BYTES = 5 * 1024 * 1024;
59
+ const POLL_MS = 150;
60
+ const HELLO_TIMEOUT_MS = 5_000;
61
+
62
+ const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
63
+
64
+ function pidAlive(pid: number): boolean {
65
+ if (!Number.isInteger(pid) || pid <= 0) return false;
66
+ try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === 'EPERM'; }
67
+ }
68
+
69
+ /**
70
+ * Starts the daemon and exits once it listens, passing on its exit code if it stops
71
+ * before that. The daemon's parent is then a process that no longer exists, so a tree
72
+ * kill of the AWM process that started it does not reach it: when Claude Code closes a
73
+ * session whose MCP server does not exit on stdin close, it kills the server's whole
74
+ * process tree, and a daemon spawned directly died with it (measured with Claude Code
75
+ * 2.1.302 on Windows). Inline, so it needs no loader in dev.
76
+ */
77
+ const LAUNCHER = [
78
+ "const c = require('node:child_process').spawn(process.argv[1], process.argv.slice(2),",
79
+ " { detached: true, windowsHide: true, stdio: ['ignore', 'inherit', 'inherit', 'ipc'] });",
80
+ "c.on('message', () => process.exit(0));",
81
+ "c.on('exit', code => process.exit(code ?? 1));",
82
+ "c.on('error', () => process.exit(1));",
83
+ ].join('\n');
84
+
85
+ export function defaultDaemonEntry(): DaemonEntry {
86
+ const here = dirname(fileURLToPath(import.meta.url));
87
+ const js = join(here, '..', 'model-daemon.js');
88
+ if (existsSync(js)) return { command: process.execPath, args: [js] };
89
+ // Running from src/ under tsx (dev, tests): the daemon needs the same loader.
90
+ const ts = join(here, '..', 'model-daemon.ts');
91
+ return { command: process.execPath, args: ['--import', import.meta.resolve('tsx'), ts] };
92
+ }
93
+
94
+ interface Pending {
95
+ op: ModelOp;
96
+ resolve: (v: any) => void;
97
+ reject: (e: Error) => void;
98
+ timer: ReturnType<typeof setTimeout>;
99
+ }
100
+
101
+ export class ModelDaemonClient {
102
+ readonly fingerprint: string;
103
+ readonly paths: DaemonPaths;
104
+ private readonly entry: DaemonEntry;
105
+ private readonly spawnEnv: Record<string, string>;
106
+ private readonly startTimeoutMs: number;
107
+ private readonly requestTimeoutMs: number;
108
+ private readonly queueTimeoutMs: number;
109
+ private socket: Socket | null = null;
110
+ private connecting: Promise<Socket> | null = null;
111
+ private pending = new Map<number, Pending>();
112
+ private nextId = 1;
113
+ daemonPid: number | null = null;
114
+ spawned = 0;
115
+
116
+ constructor(opts: ClientOptions) {
117
+ this.fingerprint = opts.fingerprint;
118
+ this.paths = modelDaemonPaths(this.fingerprint, opts.runDir);
119
+ this.entry = opts.entry ?? defaultDaemonEntry();
120
+ this.spawnEnv = opts.spawnEnv ?? {};
121
+ this.startTimeoutMs = opts.startTimeoutMs ?? envInt('AWM_MODEL_DAEMON_START_MS', 20_000);
122
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? envInt('AWM_MODEL_DAEMON_TIMEOUT_MS', REQUEST_TIMEOUT_MS);
123
+ this.queueTimeoutMs = opts.queueTimeoutMs ?? envInt('AWM_MODEL_DAEMON_QUEUE_MS', 10 * 60_000);
124
+ }
125
+
126
+ get connected(): boolean { return !!this.socket && !this.socket.destroyed; }
127
+
128
+ async request<T>(op: ModelOp, args?: unknown, lane: Lane = 'interactive'): Promise<T> {
129
+ const socket = await this.ensureConnected();
130
+ const id = this.nextId++;
131
+ return new Promise<T>((resolve, reject) => {
132
+ const p: Pending = { op, resolve, reject, timer: this.queueTimer(id, op) };
133
+ this.pending.set(id, p);
134
+ socket.write(encodeFrame({ id, op, lane, args }));
135
+ });
136
+ }
137
+
138
+ close(): void {
139
+ this.socket?.destroy();
140
+ this.socket = null;
141
+ }
142
+
143
+ /** Waiting in the queue: fail this request only, the daemon is busy, not gone. */
144
+ private queueTimer(id: number, op: ModelOp): ReturnType<typeof setTimeout> {
145
+ return setTimeout(() => {
146
+ const p = this.pending.get(id);
147
+ if (!p) return;
148
+ this.pending.delete(id);
149
+ p.reject(new DaemonUnavailableError(`model daemon: ${op} still queued after ${this.queueTimeoutMs} ms`, 'queue-timeout'));
150
+ if (this.socket && !this.socket.destroyed) this.socket.write(encodeFrame({ id: this.nextId++, op: 'cancel', args: { id } }));
151
+ }, this.queueTimeoutMs);
152
+ }
153
+
154
+ /** Running: no answer and no progress in time means the daemon is stuck. */
155
+ private runTimer(id: number, op: ModelOp): ReturnType<typeof setTimeout> {
156
+ const ms = op === 'warm' ? Math.max(this.requestTimeoutMs, WARM_TIMEOUT_MS) : this.requestTimeoutMs;
157
+ return setTimeout(() => {
158
+ const p = this.pending.get(id);
159
+ if (!p) return;
160
+ this.pending.delete(id);
161
+ p.reject(new DaemonUnavailableError(`model daemon: ${op} step ran over ${ms} ms`, 'timeout'));
162
+ this.socket?.destroy();
163
+ }, ms);
164
+ }
165
+
166
+ private ensureConnected(): Promise<Socket> {
167
+ if (this.socket && !this.socket.destroyed) return Promise.resolve(this.socket);
168
+ this.connecting ??= this.connectOrSpawn().finally(() => { this.connecting = null; });
169
+ return this.connecting;
170
+ }
171
+
172
+ private async connectOrSpawn(): Promise<Socket> {
173
+ const problem = addressProblem(this.paths);
174
+ if (problem) throw new DaemonUnavailableError(problem, 'socket-path-too-long');
175
+ if (existsSync(this.paths.runDir)) {
176
+ const unsafe = runDirProblem(this.paths.runDir);
177
+ if (unsafe) throw new DaemonUnavailableError(unsafe, 'run-dir-unsafe');
178
+ }
179
+ const deadline = Date.now() + this.startTimeoutMs;
180
+ let holdsLock = false;
181
+ let exited: number | null = null;
182
+ let last = 'unreachable';
183
+ try {
184
+ for (;;) {
185
+ const r = await this.tryConnect();
186
+ if (r.socket) return r.socket;
187
+ last = r.reason;
188
+ // A daemon of a different protocol or config, or one that cannot prove it holds
189
+ // the token, will not change by waiting.
190
+ if (r.fatal) throw new DaemonUnavailableError(`model daemon refused: ${r.reason}`, r.reason);
191
+ if (exited === EXIT_FINGERPRINT_DRIFT) {
192
+ // This process computed its fingerprint from code that has since changed on disk
193
+ // (a rebuild, an update): a daemon started now is a different daemon.
194
+ throw new DaemonUnavailableError('model daemon code changed on disk since this process started; restart the session to use the daemon', 'code-changed');
195
+ }
196
+ if (exited !== null && exited !== 0) {
197
+ throw new DaemonUnavailableError(`model daemon exited with code ${exited} during start; see ${this.paths.log}`, 'daemon-exited');
198
+ }
199
+ if (!holdsLock && !['bad token', 'starting'].includes(r.reason) && this.takeLock()) {
200
+ holdsLock = true;
201
+ // Exit code 0 is a daemon that lost the start race to another: keep polling.
202
+ this.spawnDaemon(code => { exited = code; });
203
+ }
204
+ if (Date.now() >= deadline) break;
205
+ await sleep(POLL_MS);
206
+ }
207
+ } finally {
208
+ if (holdsLock) this.releaseLock();
209
+ }
210
+ throw new DaemonUnavailableError(`model daemon did not start within ${this.startTimeoutMs} ms (${last}); see ${this.paths.log}`, 'start-timeout');
211
+ }
212
+
213
+ private tryConnect(): Promise<{ socket?: Socket; reason: string; fatal?: boolean }> {
214
+ return new Promise(resolve => {
215
+ let settled = false;
216
+ const done = (v: { socket?: Socket; reason: string; fatal?: boolean }) => {
217
+ if (settled) return;
218
+ settled = true;
219
+ clearTimeout(timer);
220
+ if (!v.socket) socket.destroy();
221
+ resolve(v);
222
+ };
223
+ const socket = connect(this.paths.address);
224
+ socket.setEncoding('utf8');
225
+ const timer = setTimeout(() => done({ reason: 'hello timeout' }), HELLO_TIMEOUT_MS);
226
+ socket.once('error', err => done({ reason: (err as NodeJS.ErrnoException).code ?? err.message }));
227
+ socket.once('close', () => done({ reason: 'closed during hello' }));
228
+ socket.once('connect', () => {
229
+ // The token is read when the challenge arrives, not now: a daemon that has
230
+ // answered has written its token, while one that only just started listening may
231
+ // not have replaced a dead daemon's file yet.
232
+ const readToken = () => { try { return readFileSync(this.paths.token, 'utf8').trim(); } catch { return ''; } };
233
+ let token = '';
234
+ const nonce = randomBytes(16).toString('hex');
235
+ let stage: 'challenge' | 'result' | 'ready' = 'challenge';
236
+ let daemonPid: number | null = null;
237
+ const reader = new FrameReader(frame => {
238
+ if (stage === 'ready') return this.onResponse(frame as ResponseFrame);
239
+ const f = frame as { t?: string; ok?: boolean; error?: string; nonce?: string; proof?: string; pid?: number };
240
+ if (f?.t === 'hello' && !f.ok) {
241
+ const reason = f.error ?? 'refused';
242
+ // 'starting': the daemon has not written its token yet. 'closing': it is
243
+ // exiting on its lease. 'bad token': our proof used a stale file. All three
244
+ // clear up by trying again.
245
+ return done({ reason, fatal: !['bad token', 'closing', 'starting'].includes(reason) });
246
+ }
247
+ if (stage === 'challenge') {
248
+ if (f?.t !== 'challenge') return done({ reason: 'unexpected handshake frame', fatal: true });
249
+ const verifies = (t: string) => !!t && proofMatches(proof(t, 'daemon', nonce, String(f.nonce), this.fingerprint), f.proof);
250
+ token = readToken();
251
+ if (!verifies(token)) {
252
+ token = readToken(); // a rename may have landed between the two reads
253
+ if (!verifies(token)) {
254
+ // Whatever answered on our address does not hold our token: send it nothing.
255
+ return done({ reason: 'daemon failed authentication', fatal: true });
256
+ }
257
+ }
258
+ daemonPid = f.pid ?? null;
259
+ stage = 'result';
260
+ socket.write(encodeFrame({ t: 'auth', proof: proof(token, 'client', nonce, String(f.nonce), this.fingerprint) }));
261
+ return;
262
+ }
263
+ if (f?.t === 'hello' && f.ok) {
264
+ stage = 'ready';
265
+ reader.limit = MAX_FRAME_BYTES;
266
+ this.daemonPid = daemonPid;
267
+ this.attach(socket);
268
+ return done({ socket, reason: 'ok' });
269
+ }
270
+ return done({ reason: 'unexpected handshake frame', fatal: true });
271
+ }, HELLO_MAX_BYTES);
272
+ socket.on('data', (chunk: string) => { if (!reader.push(chunk)) socket.destroy(); });
273
+ socket.write(encodeFrame({ t: 'hello', protocol: PROTOCOL_VERSION, fingerprint: this.fingerprint, nonce }));
274
+ });
275
+ });
276
+ }
277
+
278
+ private attach(socket: Socket): void {
279
+ this.socket = socket;
280
+ socket.removeAllListeners('close');
281
+ socket.removeAllListeners('error');
282
+ socket.on('error', () => { /* close follows */ });
283
+ socket.on('close', () => {
284
+ if (this.socket === socket) this.socket = null;
285
+ for (const [, p] of this.pending) {
286
+ clearTimeout(p.timer);
287
+ p.reject(new DaemonUnavailableError('model daemon disconnected', 'disconnected'));
288
+ }
289
+ this.pending.clear();
290
+ });
291
+ // The connection must not keep an otherwise finished process alive.
292
+ socket.unref();
293
+ }
294
+
295
+ private onResponse(frame: ResponseFrame): void {
296
+ const p = this.pending.get(frame?.id);
297
+ if (!p) return;
298
+ if (frame.started) {
299
+ clearTimeout(p.timer);
300
+ p.timer = this.runTimer(frame.id, p.op);
301
+ return;
302
+ }
303
+ if (frame.requeued) {
304
+ clearTimeout(p.timer);
305
+ p.timer = this.queueTimer(frame.id, p.op);
306
+ return;
307
+ }
308
+ this.pending.delete(frame.id);
309
+ clearTimeout(p.timer);
310
+ if (frame.ok) p.resolve(frame.result);
311
+ else p.reject(new DaemonRemoteError(frame.error ?? 'model daemon error'));
312
+ }
313
+
314
+ private takeLock(): boolean {
315
+ mkdirSync(this.paths.runDir, { recursive: true, mode: 0o700 });
316
+ for (let attempt = 0; attempt < 2; attempt++) {
317
+ try {
318
+ const fd = openSync(this.paths.lock, 'wx', 0o600);
319
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
320
+ closeSync(fd);
321
+ return true;
322
+ } catch (err) {
323
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
324
+ let holder: { pid?: number; at?: number } = {};
325
+ try { holder = JSON.parse(readFileSync(this.paths.lock, 'utf8')); } catch { /* half-written: judge by age */ }
326
+ const age = Date.now() - (holder.at ?? statMtime(this.paths.lock));
327
+ if (pidAlive(holder.pid ?? -1) && age < this.startTimeoutMs) return false;
328
+ // Two processes can both judge it stale and both start a daemon; the second to
329
+ // listen gets EADDRINUSE and exits 0, so the race costs one process start.
330
+ try { unlinkSync(this.paths.lock); } catch { /* another process took it over first */ }
331
+ }
332
+ }
333
+ return false;
334
+ }
335
+
336
+ private releaseLock(): void {
337
+ try {
338
+ const holder = JSON.parse(readFileSync(this.paths.lock, 'utf8'));
339
+ if (holder.pid === process.pid) unlinkSync(this.paths.lock);
340
+ } catch { /* gone already */ }
341
+ }
342
+
343
+ private spawnDaemon(onExit: (code: number) => void): void {
344
+ let fd: number | 'ignore' = 'ignore';
345
+ try {
346
+ if (existsSync(this.paths.log) && statSync(this.paths.log).size > LOG_MAX_BYTES) {
347
+ renameSync(this.paths.log, `${this.paths.log}.1`);
348
+ }
349
+ fd = openSync(this.paths.log, 'a', 0o600);
350
+ } catch { /* no log, still start */ }
351
+ try {
352
+ // The exit code is the launcher's: 0 once the daemon listens (or lost the race),
353
+ // otherwise the daemon's own.
354
+ const child = spawn(process.execPath, ['-e', LAUNCHER, '--', this.entry.command, ...this.entry.args], {
355
+ // Not the session's project directory: on Windows a process's cwd cannot be
356
+ // deleted or renamed, and the daemon outlives the session that started it.
357
+ cwd: this.paths.runDir,
358
+ detached: true, windowsHide: true,
359
+ env: { ...process.env, ...this.spawnEnv, AWM_MODEL_DAEMON_FINGERPRINT: this.fingerprint },
360
+ stdio: ['ignore', fd, fd],
361
+ });
362
+ child.on('error', err => { console.error(`[awm] model daemon spawn failed: ${err.message}`); onExit(-1); });
363
+ child.on('exit', code => onExit(code ?? -1));
364
+ child.unref();
365
+ this.spawned++;
366
+ } finally {
367
+ if (typeof fd === 'number') closeSync(fd);
368
+ }
369
+ }
370
+ }
371
+
372
+ function statMtime(path: string): number {
373
+ try { return statSync(path).mtimeMs; } catch { return 0; }
374
+ }