@primitive.ai/prim 0.1.0-alpha.41 → 0.1.0-alpha.43

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.
@@ -1,16 +1,673 @@
1
1
  #!/usr/bin/env node
2
- import {
3
- getClient,
4
- getSiteUrl,
5
- getTokenExpiresAt,
6
- refreshToken
7
- } from "../chunk-26VA3ADF.js";
8
2
 
9
3
  // src/daemon/server.ts
10
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
4
+ import { existsSync as existsSync5, readFileSync as readFileSync4, unlinkSync as unlinkSync3 } from "fs";
11
5
  import { createServer } from "net";
6
+ import { homedir as homedir5 } from "os";
7
+ import { join as join6 } from "path";
8
+ import { fileURLToPath } from "url";
9
+
10
+ // src/client.ts
11
+ import { existsSync, readFileSync, writeFileSync } from "fs";
12
12
  import { homedir } from "os";
13
- import { join } from "path";
13
+ import { join, resolve } from "path";
14
+ function loadEnvFile() {
15
+ const envVars = {};
16
+ const candidates = [".env.local", ".env"];
17
+ for (const file of candidates) {
18
+ const filePath = resolve(process.cwd(), file);
19
+ if (existsSync(filePath)) {
20
+ const content = readFileSync(filePath, "utf-8");
21
+ for (const line of content.split("\n")) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith("#")) continue;
24
+ const eqIdx = trimmed.indexOf("=");
25
+ if (eqIdx === -1) continue;
26
+ const key = trimmed.slice(0, eqIdx).trim();
27
+ const value = trimmed.slice(eqIdx + 1).trim();
28
+ envVars[key] = value;
29
+ }
30
+ }
31
+ }
32
+ return envVars;
33
+ }
34
+ var TOKEN_FILE_PATH = join(homedir(), ".config", "prim", "token");
35
+ var REFRESH_TOKEN_PATH = TOKEN_FILE_PATH.replace("/token", "/refresh_token");
36
+ var TOKEN_EXPIRES_PATH = join(homedir(), ".config", "prim", "token_expires_at");
37
+ var REFRESH_THRESHOLD_MS = 6e4;
38
+ function isTokenExpiringSoon() {
39
+ if (!existsSync(TOKEN_EXPIRES_PATH)) return false;
40
+ const expiresAt = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
41
+ return !Number.isNaN(expiresAt) && Date.now() >= expiresAt - REFRESH_THRESHOLD_MS;
42
+ }
43
+ function getJwtExpiry(token) {
44
+ const parts = token.split(".");
45
+ if (parts.length !== 3) return void 0;
46
+ try {
47
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
48
+ return payload.exp ? payload.exp * 1e3 : void 0;
49
+ } catch {
50
+ return void 0;
51
+ }
52
+ }
53
+ function saveTokenExpiry(token, expiresIn) {
54
+ const expiresAt = expiresIn ? Date.now() + expiresIn * 1e3 : getJwtExpiry(token);
55
+ if (expiresAt) {
56
+ writeFileSync(TOKEN_EXPIRES_PATH, String(expiresAt), { mode: 384 });
57
+ }
58
+ }
59
+ function getTokenExpiresAt() {
60
+ if (!existsSync(TOKEN_EXPIRES_PATH)) return void 0;
61
+ const val = Number(readFileSync(TOKEN_EXPIRES_PATH, "utf-8").trim());
62
+ return Number.isNaN(val) ? void 0 : val;
63
+ }
64
+ function getAuthToken() {
65
+ if (process.env.PRIM_TOKEN) {
66
+ return process.env.PRIM_TOKEN;
67
+ }
68
+ if (existsSync(TOKEN_FILE_PATH)) {
69
+ const token = readFileSync(TOKEN_FILE_PATH, "utf-8").trim();
70
+ if (token) {
71
+ return token;
72
+ }
73
+ }
74
+ const envVars = loadEnvFile();
75
+ if (envVars.PRIM_TOKEN) {
76
+ return envVars.PRIM_TOKEN;
77
+ }
78
+ return void 0;
79
+ }
80
+ var DEFAULT_API_URL = "https://api.getprimitive.ai";
81
+ function getSiteUrl() {
82
+ if (process.env.PRIM_API_URL) {
83
+ return process.env.PRIM_API_URL;
84
+ }
85
+ const envVars = loadEnvFile();
86
+ if (envVars.PRIM_API_URL) {
87
+ return envVars.PRIM_API_URL;
88
+ }
89
+ return DEFAULT_API_URL;
90
+ }
91
+ async function performTokenRefresh(options = {}) {
92
+ if (!existsSync(REFRESH_TOKEN_PATH)) {
93
+ return void 0;
94
+ }
95
+ const refreshTokenValue = readFileSync(REFRESH_TOKEN_PATH, "utf-8").trim();
96
+ if (!refreshTokenValue) {
97
+ return void 0;
98
+ }
99
+ const siteUrl = getSiteUrl();
100
+ const response = await fetch(`${siteUrl}/mcp/broker/refresh`, {
101
+ method: "POST",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ refresh_token: refreshTokenValue }),
104
+ signal: options.signal
105
+ });
106
+ if (!response.ok) {
107
+ if (options.quiet) return void 0;
108
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
109
+ process.stderr.write(
110
+ `[prim] token refresh rejected by broker: ${response.status} ${response.statusText}${detail ? ` \u2014 ${detail}` : ""}
111
+ `
112
+ );
113
+ return void 0;
114
+ }
115
+ const data = await response.json();
116
+ if (!data.access_token) {
117
+ return void 0;
118
+ }
119
+ writeFileSync(TOKEN_FILE_PATH, data.access_token, { mode: 384 });
120
+ if (data.refresh_token) {
121
+ writeFileSync(REFRESH_TOKEN_PATH, data.refresh_token, { mode: 384 });
122
+ }
123
+ saveTokenExpiry(data.access_token, data.expires_in);
124
+ return data.access_token;
125
+ }
126
+ var _refreshInFlight;
127
+ function refreshToken(options = {}) {
128
+ if (_refreshInFlight) {
129
+ return _refreshInFlight;
130
+ }
131
+ const attempt = performTokenRefresh(options).then((token) => {
132
+ if (token) {
133
+ _cachedToken = token;
134
+ }
135
+ return token;
136
+ }).finally(() => {
137
+ if (_refreshInFlight === attempt) {
138
+ _refreshInFlight = void 0;
139
+ }
140
+ });
141
+ _refreshInFlight = attempt;
142
+ return attempt;
143
+ }
144
+ var HttpError = class extends Error {
145
+ status;
146
+ constructor(status, message) {
147
+ super(message);
148
+ this.name = "HttpError";
149
+ this.status = status;
150
+ }
151
+ };
152
+ var _cachedToken;
153
+ async function request(method, path, body, options) {
154
+ const siteUrl = getSiteUrl();
155
+ const url = `${siteUrl}${path}`;
156
+ if (!_cachedToken) {
157
+ _cachedToken = getAuthToken();
158
+ }
159
+ if (_cachedToken && isTokenExpiringSoon()) {
160
+ const newToken = await refreshToken({
161
+ signal: options?.signal,
162
+ quiet: options?.quietRefresh
163
+ });
164
+ if (newToken) {
165
+ _cachedToken = newToken;
166
+ }
167
+ }
168
+ const doFetch = async (token) => {
169
+ const headers = {
170
+ "Content-Type": "application/json"
171
+ };
172
+ if (token) {
173
+ headers.Authorization = `Bearer ${token}`;
174
+ }
175
+ return fetch(url, {
176
+ method,
177
+ headers,
178
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
179
+ signal: options?.signal
180
+ });
181
+ };
182
+ const tokenUsed = _cachedToken;
183
+ let res = await doFetch(tokenUsed);
184
+ if (res.status === 401) {
185
+ const newToken = _cachedToken && _cachedToken !== tokenUsed ? _cachedToken : await refreshToken({
186
+ signal: options?.signal,
187
+ quiet: options?.quietRefresh
188
+ });
189
+ if (newToken) {
190
+ _cachedToken = newToken;
191
+ res = await doFetch(newToken);
192
+ }
193
+ }
194
+ if (!res.ok) {
195
+ if (res.status === 401) {
196
+ throw new HttpError(401, "Authentication expired. Run `prim auth login` to re-authenticate.");
197
+ }
198
+ const errorBody = await res.json().catch(() => null);
199
+ throw new HttpError(res.status, errorBody?.error ?? `HTTP ${res.status}`);
200
+ }
201
+ return res.json();
202
+ }
203
+ function getClient() {
204
+ return {
205
+ get: (path, options) => request("GET", path, void 0, options),
206
+ post: (path, body, options) => request("POST", path, body, options)
207
+ };
208
+ }
209
+
210
+ // src/flusher.ts
211
+ import { createReadStream, renameSync, unlinkSync } from "fs";
212
+ import { createInterface } from "readline";
213
+
214
+ // src/ingest-response.ts
215
+ var IngestAcknowledgementError = class extends Error {
216
+ constructor(message) {
217
+ super(message);
218
+ this.name = "IngestAcknowledgementError";
219
+ }
220
+ };
221
+ function requireDurableIngestAcknowledgement(value, expected) {
222
+ if (typeof value !== "object" || value === null) {
223
+ throw new IngestAcknowledgementError("ingest response was not an object");
224
+ }
225
+ const response = value;
226
+ if (response.disposition !== "persisted") {
227
+ throw new IngestAcknowledgementError("ingest response did not confirm persistence");
228
+ }
229
+ if (response.acknowledged !== expected) {
230
+ throw new IngestAcknowledgementError(
231
+ `ingest acknowledged ${String(response.acknowledged)} of ${String(expected)} moves`
232
+ );
233
+ }
234
+ return value;
235
+ }
236
+
237
+ // src/journal.ts
238
+ import {
239
+ appendFileSync,
240
+ closeSync,
241
+ existsSync as existsSync2,
242
+ fstatSync,
243
+ mkdirSync,
244
+ openSync,
245
+ opendirSync,
246
+ readFileSync as readFileSync2,
247
+ readSync,
248
+ readdirSync
249
+ } from "fs";
250
+ import { homedir as homedir2 } from "os";
251
+ import { dirname, join as join2 } from "path";
252
+ var JOURNAL_DIR = join2(homedir2(), ".config", "prim", "moves");
253
+ var JOURNAL_BASENAME = "journal.ndjson";
254
+ var JOURNAL_STATS_SAMPLE_BYTES = 64 * 1024;
255
+ var PENDING_STATS_MAX_FILES = 128;
256
+ function envSlug(apiUrl) {
257
+ const slug = apiUrl.replace(/^https?:\/\//, "").replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
258
+ if (slug.length === 0 || slug === "." || slug === "..") {
259
+ return "default";
260
+ }
261
+ return slug;
262
+ }
263
+ function currentEnvDir() {
264
+ return join2(JOURNAL_DIR, envSlug(getSiteUrl()));
265
+ }
266
+ function parseMoves(content) {
267
+ const moves = [];
268
+ for (const line of content.split("\n")) {
269
+ if (line.length === 0) {
270
+ continue;
271
+ }
272
+ try {
273
+ moves.push(JSON.parse(line));
274
+ } catch {
275
+ }
276
+ }
277
+ return moves;
278
+ }
279
+ function listBuckets() {
280
+ const out = [];
281
+ const envDir = currentEnvDir();
282
+ if (!existsSync2(envDir)) {
283
+ return out;
284
+ }
285
+ for (const entry of readdirSync(envDir, { withFileTypes: true })) {
286
+ if (!entry.isDirectory()) {
287
+ continue;
288
+ }
289
+ const path = join2(envDir, entry.name, JOURNAL_BASENAME);
290
+ if (existsSync2(path)) {
291
+ out.push({ bucket: entry.name, path });
292
+ }
293
+ }
294
+ return out;
295
+ }
296
+ var FLUSHING_PREFIX = `${JOURNAL_BASENAME}.flushing.`;
297
+ function oldestCapturedAt(moves) {
298
+ let oldest;
299
+ for (const move of moves) {
300
+ if (!Number.isFinite(move.capturedAt)) {
301
+ continue;
302
+ }
303
+ oldest = oldest === void 0 ? move.capturedAt : Math.min(oldest, move.capturedAt);
304
+ }
305
+ return oldest;
306
+ }
307
+ function sampleJournalFile(path, maxBytes = JOURNAL_STATS_SAMPLE_BYTES) {
308
+ const fd = openSync(path, "r");
309
+ try {
310
+ const initial = fstatSync(fd);
311
+ const target = Math.min(initial.size, Math.max(0, maxBytes));
312
+ const buffer = Buffer.allocUnsafe(target);
313
+ let sampledBytes = 0;
314
+ while (sampledBytes < target) {
315
+ const count = readSync(fd, buffer, sampledBytes, target - sampledBytes, sampledBytes);
316
+ if (count === 0) {
317
+ break;
318
+ }
319
+ sampledBytes += count;
320
+ }
321
+ const stat = fstatSync(fd);
322
+ const sampled = stat.size > sampledBytes;
323
+ let content = buffer.subarray(0, sampledBytes).toString("utf-8");
324
+ if (sampled) {
325
+ const lastNewline = content.lastIndexOf("\n");
326
+ content = lastNewline === -1 ? "" : content.slice(0, lastNewline + 1);
327
+ }
328
+ const lines = content.split("\n").filter((line) => line.length > 0);
329
+ const moves = parseMoves(content);
330
+ return {
331
+ sizeBytes: stat.size,
332
+ mtimeMs: stat.mtimeMs,
333
+ lineCount: sampled && stat.size > 0 ? Math.max(1, lines.length) : lines.length,
334
+ oldestCapturedAt: oldestCapturedAt(moves),
335
+ sampled,
336
+ sampledBytes
337
+ };
338
+ } finally {
339
+ closeSync(fd);
340
+ }
341
+ }
342
+ function parseFlushingPid(name) {
343
+ const segments = name.slice(FLUSHING_PREFIX.length).split(".");
344
+ const last = segments.length >= 2 ? segments[segments.length - 1] : void 0;
345
+ return last !== void 0 && /^[0-9]+$/.test(last) ? Number(last) : void 0;
346
+ }
347
+ function listFlushingInDir(dir, bucket, options = {}) {
348
+ if (!existsSync2(dir)) {
349
+ return [];
350
+ }
351
+ const out = [];
352
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
353
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
354
+ const entries = opendirSync(dir);
355
+ try {
356
+ let entry = entries.readSync();
357
+ while (entry && out.length < maxFiles) {
358
+ if (!entry.isFile() || !entry.name.startsWith(FLUSHING_PREFIX)) {
359
+ entry = entries.readSync();
360
+ continue;
361
+ }
362
+ const path = join2(dir, entry.name);
363
+ let sample;
364
+ try {
365
+ sample = sampleJournalFile(path, remainingBytes);
366
+ } catch (err) {
367
+ if (err.code === "ENOENT") {
368
+ entry = entries.readSync();
369
+ continue;
370
+ }
371
+ throw err;
372
+ }
373
+ remainingBytes = Math.max(0, remainingBytes - sample.sampledBytes);
374
+ out.push({
375
+ bucket,
376
+ path,
377
+ pid: parseFlushingPid(entry.name),
378
+ ...sample
379
+ });
380
+ entry = entries.readSync();
381
+ }
382
+ } finally {
383
+ entries.closeSync();
384
+ }
385
+ return out;
386
+ }
387
+ function listFlushing(options = {}) {
388
+ const envDir = currentEnvDir();
389
+ if (!existsSync2(envDir)) {
390
+ return [];
391
+ }
392
+ const out = [];
393
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
394
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
395
+ for (const entry of readdirSync(envDir, { withFileTypes: true })) {
396
+ if (out.length >= maxFiles) {
397
+ break;
398
+ }
399
+ if (entry.isDirectory()) {
400
+ const found = listFlushingInDir(join2(envDir, entry.name), entry.name, {
401
+ sampleBytes: remainingBytes,
402
+ maxFiles: maxFiles - out.length
403
+ });
404
+ out.push(...found);
405
+ remainingBytes = Math.max(
406
+ 0,
407
+ remainingBytes - found.reduce((count, file) => count + file.sampledBytes, 0)
408
+ );
409
+ }
410
+ }
411
+ return out;
412
+ }
413
+ function bucketStats(options = {}) {
414
+ const out = [];
415
+ let remainingBytes = options.sampleBytes ?? JOURNAL_STATS_SAMPLE_BYTES;
416
+ const maxFiles = options.maxFiles ?? Number.POSITIVE_INFINITY;
417
+ for (const { bucket, path } of listBuckets()) {
418
+ if (out.length >= maxFiles) {
419
+ break;
420
+ }
421
+ let sample;
422
+ try {
423
+ sample = sampleJournalFile(path, remainingBytes);
424
+ } catch (err) {
425
+ if (err.code === "ENOENT") {
426
+ continue;
427
+ }
428
+ throw err;
429
+ }
430
+ remainingBytes = Math.max(0, remainingBytes - sample.sampledBytes);
431
+ out.push({
432
+ bucket,
433
+ path,
434
+ ...sample
435
+ });
436
+ }
437
+ return out;
438
+ }
439
+ function pendingJournalStats() {
440
+ const perKindBytes = Math.floor(JOURNAL_STATS_SAMPLE_BYTES / 2);
441
+ const perKindFiles = Math.floor(PENDING_STATS_MAX_FILES / 2);
442
+ const live = bucketStats({ sampleBytes: perKindBytes, maxFiles: perKindFiles });
443
+ const stranded = listFlushing({ sampleBytes: perKindBytes, maxFiles: perKindFiles });
444
+ const timestamps = [...live, ...stranded].map((entry) => entry.oldestCapturedAt).filter((value) => value !== void 0);
445
+ const liveSampled = live.some((entry) => entry.sampled) || live.length >= perKindFiles;
446
+ const strandedSampled = stranded.some((entry) => entry.sampled) || stranded.length >= perKindFiles;
447
+ return {
448
+ pendingCount: live.reduce((count, entry) => count + entry.lineCount, 0) + stranded.reduce((count, entry) => count + entry.lineCount, 0),
449
+ oldestPendingAt: timestamps.length > 0 ? Math.min(...timestamps) : void 0,
450
+ strandedCount: stranded.reduce((count, entry) => count + entry.lineCount, 0),
451
+ strandedFileCount: stranded.length,
452
+ sampled: liveSampled || strandedSampled,
453
+ strandedSampled
454
+ };
455
+ }
456
+
457
+ // src/flusher.ts
458
+ var BATCH_SIZE = 500;
459
+ var HTTP_TIMEOUT_MS = 1e4;
460
+ var ORPHAN_QUARANTINE_MS = 6e4;
461
+ async function drainFlushingPath(flushingPath, client2 = getClient()) {
462
+ const postBatch = async (batch2) => {
463
+ const response = await client2.post(
464
+ "/api/cli/moves/ingest",
465
+ { batch: batch2 },
466
+ { signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }
467
+ );
468
+ requireDurableIngestAcknowledgement(response, batch2.length);
469
+ };
470
+ const input = createReadStream(flushingPath, { encoding: "utf-8" });
471
+ const lines = createInterface({ input, crlfDelay: Number.POSITIVE_INFINITY });
472
+ let batch = [];
473
+ let total = 0;
474
+ try {
475
+ for await (const line of lines) {
476
+ if (line.length === 0) {
477
+ continue;
478
+ }
479
+ try {
480
+ batch.push(JSON.parse(line));
481
+ } catch {
482
+ continue;
483
+ }
484
+ if (batch.length === BATCH_SIZE) {
485
+ await postBatch(batch);
486
+ total += batch.length;
487
+ batch = [];
488
+ }
489
+ }
490
+ if (batch.length > 0) {
491
+ await postBatch(batch);
492
+ total += batch.length;
493
+ }
494
+ } finally {
495
+ lines.close();
496
+ input.destroy();
497
+ }
498
+ unlinkSync(flushingPath);
499
+ return total;
500
+ }
501
+ async function drainPath(path) {
502
+ const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
503
+ try {
504
+ renameSync(path, tmpPath);
505
+ } catch (err) {
506
+ if (err.code === "ENOENT") {
507
+ return 0;
508
+ }
509
+ throw err;
510
+ }
511
+ return drainFlushingPath(tmpPath);
512
+ }
513
+ function processIsAlive(pid) {
514
+ try {
515
+ process.kill(pid, 0);
516
+ return true;
517
+ } catch {
518
+ return false;
519
+ }
520
+ }
521
+ function selectRecoverable(files, now, opts = {}) {
522
+ const quarantineMs = opts.quarantineMs ?? ORPHAN_QUARANTINE_MS;
523
+ const isAlive = opts.isAlive ?? processIsAlive;
524
+ return files.filter((f) => {
525
+ if (f.pid === void 0) {
526
+ return now - f.mtimeMs > quarantineMs;
527
+ }
528
+ return f.pid === opts.ownerPid || !isAlive(f.pid);
529
+ });
530
+ }
531
+ async function recoverOrphans(candidates = listFlushing({ sampleBytes: 0 }), options = {}) {
532
+ const summary = { flushed: 0, errors: [], failedBuckets: /* @__PURE__ */ new Set() };
533
+ const recoverable = selectRecoverable(candidates, options.now ?? Date.now(), {
534
+ ownerPid: options.ownerPid ?? process.pid,
535
+ isAlive: options.isAlive
536
+ }).sort((a, b) => a.mtimeMs - b.mtimeMs || a.path.localeCompare(b.path));
537
+ const drain = options.drain ?? drainFlushingPath;
538
+ for (const file of recoverable) {
539
+ if (summary.failedBuckets.has(file.bucket)) {
540
+ continue;
541
+ }
542
+ try {
543
+ summary.flushed += await drain(file.path);
544
+ } catch (err) {
545
+ summary.errors.push(err);
546
+ summary.failedBuckets.add(file.bucket);
547
+ }
548
+ }
549
+ return summary;
550
+ }
551
+ var FlushError = class extends Error {
552
+ flushed;
553
+ constructor(cause, flushed) {
554
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
555
+ this.name = "FlushError";
556
+ this.flushed = flushed;
557
+ }
558
+ };
559
+ async function flushOnce() {
560
+ const recovered = await recoverOrphans();
561
+ let total = recovered.flushed;
562
+ const errors = recovered.errors;
563
+ for (const { bucket, path } of listBuckets()) {
564
+ if (recovered.failedBuckets.has(bucket)) {
565
+ continue;
566
+ }
567
+ try {
568
+ total += await drainPath(path);
569
+ } catch (err) {
570
+ errors.push(err);
571
+ }
572
+ }
573
+ if (errors.length > 0) {
574
+ throw new FlushError(errors[0], total);
575
+ }
576
+ return { flushed: total };
577
+ }
578
+ var flushInFlight;
579
+ function flush() {
580
+ if (flushInFlight) {
581
+ return flushInFlight;
582
+ }
583
+ const attempt = flushOnce().finally(() => {
584
+ if (flushInFlight === attempt) {
585
+ flushInFlight = void 0;
586
+ }
587
+ });
588
+ flushInFlight = attempt;
589
+ return attempt;
590
+ }
591
+
592
+ // src/daemon/client.ts
593
+ import { createConnection } from "net";
594
+ import { homedir as homedir3 } from "os";
595
+ import { join as join3 } from "path";
596
+ var SOCK_PATH = join3(homedir3(), ".config", "prim", "sock");
597
+ var DEFAULT_TIMEOUT_MS = 250;
598
+ var nextRequestId = 1;
599
+ function daemonRequest(method, params = {}, opts = {}) {
600
+ const id = nextRequestId++;
601
+ const request2 = { id, method, params };
602
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
603
+ return new Promise((resolve2) => {
604
+ let settled = false;
605
+ let socket;
606
+ let buffer = "";
607
+ const settle = (value) => {
608
+ if (settled) {
609
+ return;
610
+ }
611
+ settled = true;
612
+ if (socket) {
613
+ try {
614
+ socket.destroy();
615
+ } catch {
616
+ }
617
+ }
618
+ resolve2(value);
619
+ };
620
+ const timer = setTimeout(() => settle(null), timeoutMs);
621
+ timer.unref();
622
+ try {
623
+ socket = createConnection(SOCK_PATH);
624
+ } catch {
625
+ clearTimeout(timer);
626
+ settle(null);
627
+ return;
628
+ }
629
+ socket.on("error", () => {
630
+ clearTimeout(timer);
631
+ settle(null);
632
+ });
633
+ socket.on("connect", () => {
634
+ try {
635
+ socket?.write(`${JSON.stringify(request2)}
636
+ `);
637
+ } catch {
638
+ clearTimeout(timer);
639
+ settle(null);
640
+ }
641
+ });
642
+ socket.on("data", (chunk) => {
643
+ buffer += chunk.toString("utf-8");
644
+ const newlineIdx = buffer.indexOf("\n");
645
+ if (newlineIdx === -1) {
646
+ return;
647
+ }
648
+ const line = buffer.slice(0, newlineIdx);
649
+ try {
650
+ const res = JSON.parse(line);
651
+ if (res.id !== id) {
652
+ clearTimeout(timer);
653
+ settle(null);
654
+ return;
655
+ }
656
+ clearTimeout(timer);
657
+ settle(res.ok && res.result !== void 0 ? res.result : null);
658
+ } catch {
659
+ clearTimeout(timer);
660
+ settle(null);
661
+ }
662
+ });
663
+ socket.on("end", () => {
664
+ if (!settled) {
665
+ clearTimeout(timer);
666
+ settle(null);
667
+ }
668
+ });
669
+ });
670
+ }
14
671
 
15
672
  // src/daemon/env-binding.ts
16
673
  function normalizeApiUrl(url) {
@@ -28,21 +685,242 @@ function assertCallerEnvMatches(callerEnv, boundEnv) {
28
685
  }
29
686
  }
30
687
 
688
+ // src/daemon/health.ts
689
+ import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
690
+ import { homedir as homedir4 } from "os";
691
+ import { dirname as dirname2, join as join4 } from "path";
692
+ var CONFIG_DIR_MODE = 448;
693
+ var HEALTH_FILE_MODE = 384;
694
+ var INGESTION_SLA_MS = 3e4;
695
+ var HEARTBEAT_FRESH_MS = 9e4;
696
+ var HEARTBEAT_RETRY_CAP_MS = 3e4;
697
+ var INGESTION_RETRY_BASE_MS = 5e3;
698
+ var INGESTION_RETRY_CAP_MS = 15e3;
699
+ var DAEMON_HEALTH_PATH = join4(homedir4(), ".config", "prim", "daemon-health.json");
700
+ function createDaemonHealthState(version, pid, startedAt2) {
701
+ return {
702
+ schemaVersion: 1,
703
+ version,
704
+ pid,
705
+ startedAt: startedAt2,
706
+ updatedAt: startedAt2,
707
+ healthy: false,
708
+ heartbeat: { healthy: false, consecutiveFailures: 0 },
709
+ ingestion: {
710
+ healthy: true,
711
+ consecutiveFailures: 0,
712
+ pendingCount: 0,
713
+ pendingSampled: false,
714
+ strandedCount: 0,
715
+ lastAcknowledgedCount: 0
716
+ }
717
+ };
718
+ }
719
+ function refreshDaemonHealth(state, now) {
720
+ state.heartbeat.healthy = state.heartbeat.consecutiveFailures === 0 && state.heartbeat.lastSuccessAt !== void 0 && now - state.heartbeat.lastSuccessAt < HEARTBEAT_FRESH_MS;
721
+ state.ingestion.healthy = state.ingestion.consecutiveFailures === 0 && !state.ingestion.pendingSampled && (state.ingestion.pendingCount === 0 || state.ingestion.oldestPendingAt !== void 0 && now - state.ingestion.oldestPendingAt <= INGESTION_SLA_MS);
722
+ state.healthy = state.heartbeat.healthy && state.ingestion.healthy;
723
+ }
724
+ function retryDelayMs(consecutiveFailures, capMs, random) {
725
+ const exponent = Math.max(0, consecutiveFailures - 1);
726
+ const base = Math.min(capMs, INGESTION_RETRY_BASE_MS * 2 ** exponent);
727
+ const jitter = 0.8 + Math.min(1, Math.max(0, random())) * 0.4;
728
+ return Math.min(capMs, Math.round(base * jitter));
729
+ }
730
+ function heartbeatRetryDelayMs(consecutiveFailures, random = Math.random) {
731
+ return retryDelayMs(consecutiveFailures, HEARTBEAT_RETRY_CAP_MS, random);
732
+ }
733
+ function ingestionRetryDelayMs(consecutiveFailures, random = Math.random) {
734
+ return retryDelayMs(consecutiveFailures, INGESTION_RETRY_CAP_MS, random);
735
+ }
736
+ function writeDaemonHealthState(state, path = DAEMON_HEALTH_PATH) {
737
+ const dir = dirname2(path);
738
+ if (!existsSync3(dir)) {
739
+ mkdirSync2(dir, { recursive: true, mode: CONFIG_DIR_MODE });
740
+ }
741
+ const tmp = join4(dir, `.${process.pid}.daemon-health.tmp`);
742
+ writeFileSync2(tmp, `${JSON.stringify(state, null, 2)}
743
+ `, { mode: HEALTH_FILE_MODE });
744
+ chmodSync(tmp, HEALTH_FILE_MODE);
745
+ renameSync2(tmp, path);
746
+ }
747
+
748
+ // src/daemon/instance-lock.ts
749
+ import { randomUUID } from "crypto";
750
+ import {
751
+ chmodSync as chmodSync2,
752
+ existsSync as existsSync4,
753
+ mkdirSync as mkdirSync3,
754
+ readFileSync as readFileSync3,
755
+ renameSync as renameSync3,
756
+ rmSync,
757
+ statSync,
758
+ unlinkSync as unlinkSync2,
759
+ writeFileSync as writeFileSync3
760
+ } from "fs";
761
+ import { join as join5 } from "path";
762
+ var DIR_MODE = 448;
763
+ var FILE_MODE = 384;
764
+ var DAEMON_STARTUP_GRACE_MS = 5e3;
765
+ function readLockRecord(path) {
766
+ try {
767
+ const value = JSON.parse(readFileSync3(path, "utf-8"));
768
+ if (Number.isInteger(value.pid) && typeof value.instanceId === "string" && typeof value.startedAt === "number") {
769
+ return value;
770
+ }
771
+ } catch {
772
+ }
773
+ return;
774
+ }
775
+ function numericPid(path) {
776
+ try {
777
+ const pid = Number(readFileSync3(path, "utf-8").trim());
778
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
779
+ } catch {
780
+ return;
781
+ }
782
+ }
783
+ function daemonProcessIsAlive(pid) {
784
+ try {
785
+ process.kill(pid, 0);
786
+ return true;
787
+ } catch {
788
+ return false;
789
+ }
790
+ }
791
+ function readDaemonOwner(configDir) {
792
+ return readLockRecord(join5(configDir, "daemon.lock", "owner.json"));
793
+ }
794
+ function readDaemonPid(configDir) {
795
+ const path = join5(configDir, "daemon.pid");
796
+ const pid = numericPid(path);
797
+ if (pid === void 0) {
798
+ return;
799
+ }
800
+ try {
801
+ return { pid, mtimeMs: statSync(path).mtimeMs };
802
+ } catch {
803
+ return;
804
+ }
805
+ }
806
+ function acquireDaemonOwnership(configDir, opts = {}) {
807
+ const pid = opts.pid ?? process.pid;
808
+ const now = opts.now ?? Date.now();
809
+ const isAlive = opts.isAlive ?? daemonProcessIsAlive;
810
+ const lockDir = join5(configDir, "daemon.lock");
811
+ const ownerPath = join5(lockDir, "owner.json");
812
+ const pidPath = join5(configDir, "daemon.pid");
813
+ const instanceId = randomUUID();
814
+ if (!existsSync4(configDir)) {
815
+ mkdirSync3(configDir, { recursive: true, mode: DIR_MODE });
816
+ }
817
+ for (; ; ) {
818
+ try {
819
+ mkdirSync3(lockDir, { mode: DIR_MODE });
820
+ break;
821
+ } catch (err) {
822
+ if (err.code !== "EEXIST") {
823
+ throw err;
824
+ }
825
+ const existing = readLockRecord(ownerPath);
826
+ if (existing && isAlive(existing.pid)) {
827
+ throw new Error(`daemon already running (pid=${String(existing.pid)})`);
828
+ }
829
+ if (!existing) {
830
+ let mtimeMs;
831
+ try {
832
+ mtimeMs = statSync(lockDir).mtimeMs;
833
+ } catch (statErr) {
834
+ if (statErr.code === "ENOENT") {
835
+ continue;
836
+ }
837
+ throw statErr;
838
+ }
839
+ if (now - mtimeMs < DAEMON_STARTUP_GRACE_MS) {
840
+ throw new Error("daemon startup already in progress");
841
+ }
842
+ }
843
+ const stale = `${lockDir}.stale.${String(pid)}.${instanceId}`;
844
+ try {
845
+ renameSync3(lockDir, stale);
846
+ rmSync(stale, { recursive: true, force: true });
847
+ } catch (renameErr) {
848
+ if (renameErr.code !== "ENOENT") {
849
+ throw renameErr;
850
+ }
851
+ }
852
+ }
853
+ }
854
+ const ownership2 = {
855
+ configDir,
856
+ lockDir,
857
+ ownerPath,
858
+ pidPath,
859
+ pid,
860
+ instanceId
861
+ };
862
+ const record = { pid, instanceId, startedAt: now };
863
+ writeFileSync3(ownerPath, `${JSON.stringify(record)}
864
+ `, { mode: FILE_MODE });
865
+ chmodSync2(ownerPath, FILE_MODE);
866
+ const legacyPid = numericPid(pidPath);
867
+ if (legacyPid !== void 0 && legacyPid !== pid && isAlive(legacyPid)) {
868
+ rmSync(lockDir, { recursive: true, force: true });
869
+ throw new Error(`daemon already running (pid=${String(legacyPid)})`);
870
+ }
871
+ writeFileSync3(pidPath, String(pid), { mode: FILE_MODE });
872
+ chmodSync2(pidPath, FILE_MODE);
873
+ return ownership2;
874
+ }
875
+ function ownsDaemonInstance(ownership2) {
876
+ const current = readLockRecord(ownership2.ownerPath);
877
+ return current?.pid === ownership2.pid && current.instanceId === ownership2.instanceId;
878
+ }
879
+ function releaseDaemonOwnership(ownership2, socketPath) {
880
+ if (!ownsDaemonInstance(ownership2)) {
881
+ return false;
882
+ }
883
+ let cleanupError;
884
+ if (socketPath) {
885
+ try {
886
+ unlinkSync2(socketPath);
887
+ } catch (err) {
888
+ if (err.code !== "ENOENT") {
889
+ cleanupError = err;
890
+ }
891
+ }
892
+ }
893
+ if (numericPid(ownership2.pidPath) === ownership2.pid) {
894
+ try {
895
+ unlinkSync2(ownership2.pidPath);
896
+ } catch (err) {
897
+ if (err.code !== "ENOENT") {
898
+ cleanupError ??= err;
899
+ }
900
+ }
901
+ }
902
+ rmSync(ownership2.lockDir, { recursive: true, force: true });
903
+ if (cleanupError) {
904
+ throw cleanupError;
905
+ }
906
+ return true;
907
+ }
908
+
31
909
  // src/daemon/server.ts
32
- var CONFIG_DIR = join(homedir(), ".config", "prim");
33
- var SOCK_PATH = join(CONFIG_DIR, "sock");
34
- var PID_PATH = join(CONFIG_DIR, "daemon.pid");
910
+ var CONFIG_DIR = join6(homedir5(), ".config", "prim");
911
+ var SOCK_PATH2 = join6(CONFIG_DIR, "sock");
35
912
  var HEARTBEAT_INTERVAL_MS = 3e4;
913
+ var INGESTION_POLL_INTERVAL_MS = 15e3;
36
914
  var TOKEN_CHECK_INTERVAL_MS = 6e4;
37
915
  var TOKEN_REFRESH_THRESHOLD_MS = 9e4;
38
916
  var HTTP_PROXY_TIMEOUT_MS = 1e4;
39
917
  var PRESENCE_FRESH_WINDOW_MS = 9e4;
40
- var SOCKET_DIR_MODE = 448;
41
- var PID_FILE_MODE = 384;
42
918
  var EXIT_OK = 0;
43
919
  var EXIT_CRASH = 1;
44
920
  var startedAt = Date.now();
45
921
  var client = getClient();
922
+ var runtimeVersion = resolveRuntimeVersion();
923
+ var daemonHealth = createDaemonHealthState(runtimeVersion, process.pid, startedAt);
46
924
  var activeSessionId = process.env.PRIM_DAEMON_SESSION_ID ?? `daemon-${process.pid}`;
47
925
  var lastHeartbeatAt;
48
926
  var lastOnlineCount;
@@ -50,57 +928,165 @@ var lastOnlineNames;
50
928
  var lastOnlineTeammates;
51
929
  var lastOkAtLocal;
52
930
  var heartbeatTimer;
931
+ var ingestionTimer;
53
932
  var tokenCheckTimer;
54
- function processIsAlive(pid) {
933
+ var heartbeatInFlight;
934
+ var heartbeatRerunRequested = false;
935
+ var ownership;
936
+ var socketServer;
937
+ var shuttingDown = false;
938
+ function resolveRuntimeVersion() {
939
+ if (process.env.PRIM_RUNTIME_VERSION) {
940
+ return process.env.PRIM_RUNTIME_VERSION;
941
+ }
55
942
  try {
56
- process.kill(pid, 0);
57
- return true;
943
+ const here = fileURLToPath(new URL(".", import.meta.url));
944
+ const pkg = JSON.parse(readFileSync4(join6(here, "..", "..", "package.json"), "utf-8"));
945
+ return pkg.version ?? "unknown";
58
946
  } catch {
59
- return false;
947
+ return "unknown";
60
948
  }
61
949
  }
62
- function takePidfile() {
63
- if (existsSync(PID_PATH)) {
64
- const existing = Number(readFileSync(PID_PATH, "utf-8").trim());
65
- if (!Number.isNaN(existing) && processIsAlive(existing)) {
66
- throw new Error(`daemon already running (pid=${existing})`);
950
+ function errorMessage(err) {
951
+ return err instanceof Error ? err.message : String(err);
952
+ }
953
+ function persistHealth() {
954
+ refreshDaemonHealth(daemonHealth, Date.now());
955
+ daemonHealth.updatedAt = Date.now();
956
+ try {
957
+ writeDaemonHealthState(daemonHealth);
958
+ } catch (err) {
959
+ process.stderr.write(`[prim-daemon] health-state error: ${errorMessage(err)}
960
+ `);
961
+ }
962
+ }
963
+ function updatePendingHealth() {
964
+ const pending = pendingJournalStats();
965
+ daemonHealth.ingestion.pendingCount = pending.pendingCount;
966
+ daemonHealth.ingestion.pendingSampled = pending.sampled;
967
+ daemonHealth.ingestion.oldestPendingAt = pending.oldestPendingAt;
968
+ daemonHealth.ingestion.strandedCount = pending.strandedCount;
969
+ }
970
+ async function takeOwnership() {
971
+ const now = Date.now();
972
+ const recordedOwner = readDaemonOwner(CONFIG_DIR);
973
+ const recordedPid = readDaemonPid(CONFIG_DIR);
974
+ const candidatePids = new Set(
975
+ [recordedOwner?.pid, recordedPid?.pid].filter(
976
+ (pid) => pid !== void 0 && daemonProcessIsAlive(pid)
977
+ )
978
+ );
979
+ if (existsSync5(SOCK_PATH2) || candidatePids.size > 0) {
980
+ const snapshot = await daemonRequest(
981
+ "status_snapshot",
982
+ {},
983
+ { timeoutMs: 500 }
984
+ );
985
+ if (typeof snapshot?.pid === "number") {
986
+ throw new Error(`daemon already running (pid=${String(snapshot.pid)})`);
67
987
  }
68
988
  }
69
- if (!existsSync(CONFIG_DIR)) {
70
- mkdirSync(CONFIG_DIR, { recursive: true, mode: SOCKET_DIR_MODE });
989
+ const ownerIsStarting = recordedOwner !== void 0 && candidatePids.has(recordedOwner.pid) && now - recordedOwner.startedAt < DAEMON_STARTUP_GRACE_MS;
990
+ const legacyIsStarting = recordedPid !== void 0 && candidatePids.has(recordedPid.pid) && now - recordedPid.mtimeMs < DAEMON_STARTUP_GRACE_MS;
991
+ if (ownerIsStarting || legacyIsStarting) {
992
+ throw new Error("daemon startup already in progress");
71
993
  }
72
- writeFileSync(PID_PATH, String(process.pid), { mode: PID_FILE_MODE });
73
- }
74
- function cleanup() {
75
- try {
76
- unlinkSync(SOCK_PATH);
77
- } catch {
994
+ if (recordedOwner !== void 0 && candidatePids.has(recordedOwner.pid)) {
995
+ throw new Error(
996
+ `daemon ownership held by live pid=${String(recordedOwner.pid)} (socket unavailable)`
997
+ );
998
+ }
999
+ if (recordedPid !== void 0 && candidatePids.has(recordedPid.pid)) {
1000
+ throw new Error(
1001
+ `legacy daemon pid=${String(recordedPid.pid)} is still alive (socket unavailable)`
1002
+ );
78
1003
  }
1004
+ ownership = acquireDaemonOwnership(CONFIG_DIR);
79
1005
  try {
80
- unlinkSync(PID_PATH);
81
- } catch {
1006
+ unlinkSync3(SOCK_PATH2);
1007
+ } catch (err) {
1008
+ if (err.code !== "ENOENT") {
1009
+ throw err;
1010
+ }
1011
+ }
1012
+ }
1013
+ function cleanup() {
1014
+ if (ownership) {
1015
+ try {
1016
+ releaseDaemonOwnership(ownership, SOCK_PATH2);
1017
+ } catch (err) {
1018
+ process.stderr.write(`[prim-daemon] cleanup error: ${errorMessage(err)}
1019
+ `);
1020
+ }
82
1021
  }
83
1022
  }
84
- async function sendHeartbeat() {
1023
+ async function performHeartbeat() {
1024
+ daemonHealth.heartbeat.lastAttemptAt = Date.now();
1025
+ persistHealth();
85
1026
  try {
86
- const result = await client.post("/api/cli/presence/heartbeat", {
87
- sessionId: activeSessionId
88
- });
1027
+ const result = await client.post(
1028
+ "/api/cli/presence/heartbeat",
1029
+ { sessionId: activeSessionId },
1030
+ { signal: AbortSignal.timeout(HTTP_PROXY_TIMEOUT_MS) }
1031
+ );
89
1032
  if (result.accepted) {
90
- lastOkAtLocal = Date.now();
1033
+ const now = Date.now();
1034
+ lastOkAtLocal = now;
1035
+ daemonHealth.heartbeat.lastSuccessAt = now;
1036
+ daemonHealth.heartbeat.lastError = void 0;
1037
+ daemonHealth.heartbeat.consecutiveFailures = 0;
91
1038
  if (typeof result.lastHeartbeatAt === "number") {
92
1039
  lastHeartbeatAt = result.lastHeartbeatAt;
93
1040
  }
94
1041
  lastOnlineCount = typeof result.onlineCount === "number" ? result.onlineCount : void 0;
95
1042
  lastOnlineNames = Array.isArray(result.onlineNames) ? result.onlineNames : void 0;
96
1043
  lastOnlineTeammates = Array.isArray(result.onlineTeammates) ? result.onlineTeammates : void 0;
1044
+ } else {
1045
+ daemonHealth.heartbeat.lastRejectedAt = Date.now();
1046
+ daemonHealth.heartbeat.lastError = typeof result.unavailable === "string" ? result.unavailable : "heartbeat rejected";
1047
+ daemonHealth.heartbeat.consecutiveFailures += 1;
97
1048
  }
1049
+ persistHealth();
98
1050
  } catch (err) {
99
- process.stderr.write(
100
- `[prim-daemon] heartbeat error: ${err instanceof Error ? err.message : String(err)}
101
- `
102
- );
1051
+ daemonHealth.heartbeat.lastError = errorMessage(err);
1052
+ daemonHealth.heartbeat.consecutiveFailures += 1;
1053
+ persistHealth();
1054
+ process.stderr.write(`[prim-daemon] heartbeat error: ${errorMessage(err)}
1055
+ `);
1056
+ }
1057
+ }
1058
+ function sendHeartbeat() {
1059
+ if (heartbeatInFlight) {
1060
+ heartbeatRerunRequested = true;
1061
+ return heartbeatInFlight;
1062
+ }
1063
+ if (heartbeatTimer) {
1064
+ clearTimeout(heartbeatTimer);
1065
+ heartbeatTimer = void 0;
1066
+ }
1067
+ heartbeatInFlight = (async () => {
1068
+ do {
1069
+ heartbeatRerunRequested = false;
1070
+ await performHeartbeat();
1071
+ } while (heartbeatRerunRequested && !shuttingDown);
1072
+ })().finally(() => {
1073
+ heartbeatInFlight = void 0;
1074
+ scheduleHeartbeat();
1075
+ });
1076
+ return heartbeatInFlight;
1077
+ }
1078
+ function scheduleHeartbeat() {
1079
+ if (shuttingDown) {
1080
+ return;
103
1081
  }
1082
+ const failures = daemonHealth.heartbeat.consecutiveFailures;
1083
+ const delay = failures > 0 ? heartbeatRetryDelayMs(failures) : HEARTBEAT_INTERVAL_MS;
1084
+ daemonHealth.heartbeat.nextRetryAt = failures > 0 ? Date.now() + delay : void 0;
1085
+ persistHealth();
1086
+ heartbeatTimer = setTimeout(() => {
1087
+ heartbeatTimer = void 0;
1088
+ void sendHeartbeat();
1089
+ }, delay);
104
1090
  }
105
1091
  async function ensureTokenFresh() {
106
1092
  const expiresAt = getTokenExpiresAt();
@@ -109,11 +1095,70 @@ async function ensureTokenFresh() {
109
1095
  }
110
1096
  if (Date.now() >= expiresAt - TOKEN_REFRESH_THRESHOLD_MS) {
111
1097
  try {
112
- await refreshToken();
1098
+ await refreshToken({ signal: AbortSignal.timeout(HTTP_PROXY_TIMEOUT_MS) });
113
1099
  } catch {
114
1100
  }
115
1101
  }
116
1102
  }
1103
+ function scheduleIngestion(delayMs) {
1104
+ if (shuttingDown) {
1105
+ return;
1106
+ }
1107
+ ingestionTimer = setTimeout(() => {
1108
+ ingestionTimer = void 0;
1109
+ void runIngestionLoop();
1110
+ }, delayMs);
1111
+ }
1112
+ async function runIngestionLoop() {
1113
+ try {
1114
+ updatePendingHealth();
1115
+ } catch (err) {
1116
+ const failures = daemonHealth.ingestion.consecutiveFailures + 1;
1117
+ const delay = ingestionRetryDelayMs(failures);
1118
+ daemonHealth.ingestion.consecutiveFailures = failures;
1119
+ daemonHealth.ingestion.lastError = `journal scan failed: ${errorMessage(err)}`;
1120
+ daemonHealth.ingestion.nextRetryAt = Date.now() + delay;
1121
+ persistHealth();
1122
+ scheduleIngestion(delay);
1123
+ return;
1124
+ }
1125
+ if (daemonHealth.ingestion.pendingCount === 0 && !daemonHealth.ingestion.pendingSampled) {
1126
+ daemonHealth.ingestion.consecutiveFailures = 0;
1127
+ daemonHealth.ingestion.lastError = void 0;
1128
+ daemonHealth.ingestion.nextRetryAt = void 0;
1129
+ persistHealth();
1130
+ scheduleIngestion(INGESTION_POLL_INTERVAL_MS);
1131
+ return;
1132
+ }
1133
+ daemonHealth.ingestion.lastAttemptAt = Date.now();
1134
+ daemonHealth.ingestion.nextRetryAt = void 0;
1135
+ persistHealth();
1136
+ try {
1137
+ const result = await flush();
1138
+ daemonHealth.ingestion.lastSuccessAt = Date.now();
1139
+ daemonHealth.ingestion.lastAcknowledgedCount = result.flushed;
1140
+ daemonHealth.ingestion.consecutiveFailures = 0;
1141
+ daemonHealth.ingestion.lastError = void 0;
1142
+ updatePendingHealth();
1143
+ persistHealth();
1144
+ scheduleIngestion(INGESTION_POLL_INTERVAL_MS);
1145
+ } catch (err) {
1146
+ const failures = daemonHealth.ingestion.consecutiveFailures + 1;
1147
+ const delay = ingestionRetryDelayMs(failures);
1148
+ daemonHealth.ingestion.lastAcknowledgedCount = err instanceof FlushError ? err.flushed : 0;
1149
+ daemonHealth.ingestion.consecutiveFailures = failures;
1150
+ daemonHealth.ingestion.lastError = errorMessage(err);
1151
+ daemonHealth.ingestion.nextRetryAt = Date.now() + delay;
1152
+ try {
1153
+ updatePendingHealth();
1154
+ } catch {
1155
+ }
1156
+ persistHealth();
1157
+ process.stderr.write(`[prim-daemon] ingestion error: ${errorMessage(err)}
1158
+ `);
1159
+ scheduleIngestion(delay);
1160
+ }
1161
+ }
117
1162
  async function handleConflictCheck(params) {
118
1163
  assertCallerEnvMatches(params.callerEnv, getSiteUrl());
119
1164
  if (typeof params.file !== "string") {
@@ -139,11 +1184,22 @@ async function proxyGet(params, allowedPrefix) {
139
1184
  return await client.get(path, { signal: AbortSignal.timeout(HTTP_PROXY_TIMEOUT_MS) });
140
1185
  }
141
1186
  function handleStatusSnapshot(params) {
1187
+ const wasHealthy = daemonHealth.healthy;
1188
+ const heartbeatWasHealthy = daemonHealth.heartbeat.healthy;
1189
+ const ingestionWasHealthy = daemonHealth.ingestion.healthy;
1190
+ refreshDaemonHealth(daemonHealth, Date.now());
1191
+ if (wasHealthy !== daemonHealth.healthy || heartbeatWasHealthy !== daemonHealth.heartbeat.healthy || ingestionWasHealthy !== daemonHealth.ingestion.healthy) {
1192
+ persistHealth();
1193
+ }
142
1194
  const base = {
143
1195
  pid: process.pid,
144
1196
  uptimeMs: Date.now() - startedAt,
145
1197
  sessionId: activeSessionId,
146
- lastHeartbeatAt
1198
+ lastHeartbeatAt,
1199
+ version: runtimeVersion,
1200
+ healthy: daemonHealth.healthy,
1201
+ heartbeat: { ...daemonHealth.heartbeat },
1202
+ ingestion: { ...daemonHealth.ingestion }
147
1203
  };
148
1204
  if (isCrossEnv(params.callerEnv, getSiteUrl())) {
149
1205
  return {
@@ -155,7 +1211,7 @@ function handleStatusSnapshot(params) {
155
1211
  envMismatch: true
156
1212
  };
157
1213
  }
158
- const presenceFresh = lastOkAtLocal !== void 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
1214
+ const presenceFresh = lastOkAtLocal !== void 0 && daemonHealth.heartbeat.consecutiveFailures === 0 && Date.now() - lastOkAtLocal < PRESENCE_FRESH_WINDOW_MS;
159
1215
  const presenceStale = lastOkAtLocal !== void 0 && !presenceFresh;
160
1216
  return {
161
1217
  ...base,
@@ -245,68 +1301,85 @@ function handleConnection(conn) {
245
1301
  conn.on("error", () => {
246
1302
  });
247
1303
  }
248
- function startSocketServer() {
1304
+ function shutdown(exitCode, reason) {
1305
+ if (shuttingDown) {
1306
+ return;
1307
+ }
1308
+ shuttingDown = true;
1309
+ process.stderr.write(`${reason}
1310
+ `);
1311
+ stopTimers();
249
1312
  try {
250
- unlinkSync(SOCK_PATH);
1313
+ socketServer?.close();
251
1314
  } catch {
252
1315
  }
253
- const server = createServer(handleConnection);
254
- server.on("error", (err) => {
255
- process.stderr.write(`[prim-daemon] socket error: ${err.message}
256
- `);
1316
+ cleanup();
1317
+ process.exit(exitCode);
1318
+ }
1319
+ function startSocketServer() {
1320
+ socketServer = createServer(handleConnection);
1321
+ socketServer.on("error", (err) => {
1322
+ shutdown(EXIT_CRASH, `[prim-daemon] fatal socket error: ${err.message}`);
257
1323
  });
258
- server.listen(SOCK_PATH, () => {
259
- process.stderr.write(`[prim-daemon] listening on ${SOCK_PATH}
1324
+ socketServer.listen(SOCK_PATH2, () => {
1325
+ process.stderr.write(`[prim-daemon] listening on ${SOCK_PATH2}
260
1326
  `);
1327
+ startTimers();
1328
+ process.stderr.write(
1329
+ `[prim-daemon] started (pid=${process.pid}, session=${activeSessionId}, version=${runtimeVersion})
1330
+ `
1331
+ );
261
1332
  });
262
1333
  }
263
1334
  function startTimers() {
1335
+ persistHealth();
264
1336
  void sendHeartbeat();
265
- heartbeatTimer = setInterval(() => {
266
- void sendHeartbeat();
267
- }, HEARTBEAT_INTERVAL_MS);
268
- tokenCheckTimer = setInterval(() => {
269
- void ensureTokenFresh();
270
- }, TOKEN_CHECK_INTERVAL_MS);
1337
+ void runIngestionLoop();
1338
+ void runTokenCheckLoop();
1339
+ }
1340
+ async function runTokenCheckLoop() {
1341
+ await ensureTokenFresh();
1342
+ if (!shuttingDown) {
1343
+ tokenCheckTimer = setTimeout(() => {
1344
+ tokenCheckTimer = void 0;
1345
+ void runTokenCheckLoop();
1346
+ }, TOKEN_CHECK_INTERVAL_MS);
1347
+ }
271
1348
  }
272
1349
  function stopTimers() {
273
1350
  if (heartbeatTimer) {
274
- clearInterval(heartbeatTimer);
1351
+ clearTimeout(heartbeatTimer);
1352
+ }
1353
+ if (ingestionTimer) {
1354
+ clearTimeout(ingestionTimer);
275
1355
  }
276
1356
  if (tokenCheckTimer) {
277
- clearInterval(tokenCheckTimer);
1357
+ clearTimeout(tokenCheckTimer);
278
1358
  }
279
1359
  }
280
1360
  function installSignalHandlers() {
281
1361
  for (const signal of ["SIGTERM", "SIGINT"]) {
282
1362
  process.on(signal, () => {
283
- process.stderr.write(`[prim-daemon] ${signal}, shutting down (pid=${process.pid})
284
- `);
285
- stopTimers();
286
- cleanup();
287
- process.exit(EXIT_OK);
1363
+ shutdown(EXIT_OK, `[prim-daemon] ${signal}, shutting down (pid=${process.pid})`);
288
1364
  });
289
1365
  }
290
1366
  process.on("uncaughtException", (err) => {
291
- process.stderr.write(`[prim-daemon] uncaught: ${err.message}
292
- `);
293
- stopTimers();
294
- cleanup();
295
- process.exit(EXIT_CRASH);
1367
+ shutdown(EXIT_CRASH, `[prim-daemon] uncaught: ${err.message}`);
1368
+ });
1369
+ process.on("unhandledRejection", (err) => {
1370
+ shutdown(EXIT_CRASH, `[prim-daemon] unhandled rejection: ${errorMessage(err)}`);
296
1371
  });
297
1372
  }
298
- function main() {
1373
+ async function main() {
299
1374
  try {
300
- takePidfile();
1375
+ await takeOwnership();
301
1376
  } catch (err) {
302
- process.stderr.write(`[prim-daemon] ${err instanceof Error ? err.message : String(err)}
1377
+ process.stderr.write(`[prim-daemon] ${errorMessage(err)}
303
1378
  `);
1379
+ cleanup();
304
1380
  process.exit(EXIT_CRASH);
305
1381
  }
306
1382
  installSignalHandlers();
307
1383
  startSocketServer();
308
- startTimers();
309
- process.stderr.write(`[prim-daemon] started (pid=${process.pid}, session=${activeSessionId})
310
- `);
311
1384
  }
312
- main();
1385
+ void main();