primcli 1.34.0 → 1.35.0
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.
- package/README.md +193 -11
- package/bin/claude-pending-mail.mjs +342 -0
- package/bin/claude-wake.mjs +36 -6
- package/bin/run.js +20 -0
- package/dist/{cli-config-x39ZVsXJ.js → cli-config-DmhlcS3s.js} +5 -351
- package/dist/json-output-KAIuP9rG.js +254 -0
- package/dist/listen-background-D6UhIbkO.js +647 -0
- package/dist/oclif/index.js +15002 -12423
- package/dist/oclif/listen-supervisor.js +2 -0
- package/dist/oclif/proxy-auto-detect.js +2 -1
- package/dist/oclif/root-signup-hint.js +1 -1
- package/dist/shared-mail-files-Cbwnf79a.js +351 -0
- package/man/primitive.1 +36 -0
- package/package.json +4 -1
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { _ as acquireListenLock, a as mailLockBusy, b as listenProcessIdentity, d as readMailJson, f as removeMailFile, g as ListenStateError, h as writeMailJson, i as mailId, l as privateMailDirectory, o as mailObject, s as mailString, v as compareListenProcessIdentity } from "./shared-mail-files-Cbwnf79a.js";
|
|
2
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
//#region src/oclif/listen-background.ts
|
|
8
|
+
const BACKGROUND_LISTEN_TOKEN_ENV = "PRIMITIVE_LISTEN_BACKGROUND_TOKEN";
|
|
9
|
+
const BACKGROUND_LISTEN_TARGET_ENV = "PRIMITIVE_LISTEN_BACKGROUND_TARGET";
|
|
10
|
+
const SUPERVISOR_ENV = "PRIMITIVE_LISTEN_SUPERVISOR";
|
|
11
|
+
const SUPERVISOR_ARGV_ENV = "PRIMITIVE_LISTEN_SUPERVISOR_ARGV";
|
|
12
|
+
const SUPERVISOR_WORKER_ENV = "PRIMITIVE_LISTEN_SUPERVISOR_WORKER";
|
|
13
|
+
const STALE_AFTER_MS = 15e3;
|
|
14
|
+
const RESTART_WINDOW_MS = 10 * 6e4;
|
|
15
|
+
const HEALTHY_RESET_MS = 10 * 6e4;
|
|
16
|
+
const MAX_FAILURES = 5;
|
|
17
|
+
const phases = [
|
|
18
|
+
"starting",
|
|
19
|
+
"receiving",
|
|
20
|
+
"reconnecting",
|
|
21
|
+
"stopped",
|
|
22
|
+
"failed"
|
|
23
|
+
];
|
|
24
|
+
const failureCodes = [
|
|
25
|
+
"native-session-unavailable",
|
|
26
|
+
"notification-outcome-unknown",
|
|
27
|
+
"connection-changed",
|
|
28
|
+
"receiving-failed",
|
|
29
|
+
"restart-budget-exhausted"
|
|
30
|
+
];
|
|
31
|
+
function knownFailureCode(value) {
|
|
32
|
+
return failureCodes.includes(value) ? value : null;
|
|
33
|
+
}
|
|
34
|
+
function failureGuidance(code) {
|
|
35
|
+
switch (code) {
|
|
36
|
+
case "native-session-unavailable": return "Open the same native session with local-session support and check its socket permissions before restarting the listener.";
|
|
37
|
+
case "notification-outcome-unknown": return "A notification outcome is unknown and is held. Inspect the exact session and saved receipts before any manual resend; do not delete receipts or send again automatically.";
|
|
38
|
+
case "connection-changed": return "The selected connection changed. Check the intended profile and API origin before starting the listener again.";
|
|
39
|
+
case "receiving-failed": return "Inspect listener status and run the same command without --background for details.";
|
|
40
|
+
case "restart-budget-exhausted": return "The listener repeatedly exited. Inspect listener status before starting it again.";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function backgroundListenToken(env = process.env) {
|
|
44
|
+
const value = env[BACKGROUND_LISTEN_TOKEN_ENV];
|
|
45
|
+
return value === void 0 ? null : mailId(value);
|
|
46
|
+
}
|
|
47
|
+
function configurationFingerprint(value) {
|
|
48
|
+
if (value === void 0) return null;
|
|
49
|
+
if (!/^[a-f0-9]{64}$/i.test(value)) throw new ListenStateError("Listener configuration fingerprint is invalid.");
|
|
50
|
+
return value.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
/** Verify the parent's selection before a child reads or mutates listener state. */
|
|
53
|
+
function verifyBackgroundListenTarget(target, env = process.env) {
|
|
54
|
+
if (backgroundListenToken(env) === null) return;
|
|
55
|
+
try {
|
|
56
|
+
const expected = mailObject(JSON.parse(env["PRIMITIVE_LISTEN_BACKGROUND_TARGET"] ?? ""), [
|
|
57
|
+
"scope",
|
|
58
|
+
"threadId",
|
|
59
|
+
"configDir",
|
|
60
|
+
"configuration"
|
|
61
|
+
]);
|
|
62
|
+
if (expected.configuration !== null && typeof expected.configuration !== "string") throw new Error("Invalid configuration fingerprint");
|
|
63
|
+
const configuration = configurationFingerprint(expected.configuration === null ? void 0 : String(expected.configuration));
|
|
64
|
+
const configDir = mailString(expected.configDir, 4096);
|
|
65
|
+
if (mailString(expected.scope) !== mailString(target.scope) || mailId(expected.threadId) !== mailId(target.threadId) || configDir !== realpathSync(configDir) || configDir !== realpathSync(target.configDir) || configuration !== configurationFingerprint(target.configuration)) throw new Error("Changed listener target");
|
|
66
|
+
} catch {
|
|
67
|
+
throw new ListenStateError("Background listener target changed or its parent expectation is invalid. No listener was started.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function configDirectory(path, create) {
|
|
71
|
+
let info;
|
|
72
|
+
try {
|
|
73
|
+
info = lstatSync(path);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (!create || error.code !== "ENOENT") throw error;
|
|
76
|
+
privateMailDirectory(path, true);
|
|
77
|
+
info = lstatSync(path);
|
|
78
|
+
}
|
|
79
|
+
if (!info.isDirectory() || process.platform !== "win32" && (info.uid !== process.getuid?.() || (info.mode & 18) !== 0)) throw new ListenStateError("Listener config directory must be an owned directory without group or other write access, not a symlink.");
|
|
80
|
+
}
|
|
81
|
+
function files(target, create = false) {
|
|
82
|
+
const key = createHash("sha256").update(JSON.stringify([mailString(target.scope), mailId(target.threadId)])).digest("hex");
|
|
83
|
+
const root = join(target.configDir, "listen-background");
|
|
84
|
+
const directory = join(root, key);
|
|
85
|
+
for (const path of [
|
|
86
|
+
target.configDir,
|
|
87
|
+
root,
|
|
88
|
+
directory
|
|
89
|
+
]) try {
|
|
90
|
+
if (path === target.configDir) configDirectory(path, create);
|
|
91
|
+
else privateMailDirectory(path, create);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!create && error.code === "ENOENT") break;
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
directory,
|
|
98
|
+
state: join(directory, "state.json"),
|
|
99
|
+
stop: join(directory, "stop.json"),
|
|
100
|
+
supervisor: join(directory, "supervisor.json")
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function readSupervisor(path) {
|
|
104
|
+
const value = readMailJson(path, 2048);
|
|
105
|
+
if (value === null) return null;
|
|
106
|
+
const row = mailObject(value, [
|
|
107
|
+
"version",
|
|
108
|
+
"token",
|
|
109
|
+
"pid",
|
|
110
|
+
"identity",
|
|
111
|
+
"phase",
|
|
112
|
+
"updatedAt",
|
|
113
|
+
"failureCode"
|
|
114
|
+
]);
|
|
115
|
+
if (row.version !== 1 || !Number.isSafeInteger(row.pid) || Number(row.pid) < 1 || ![
|
|
116
|
+
"starting",
|
|
117
|
+
"running",
|
|
118
|
+
"stopped",
|
|
119
|
+
"failed"
|
|
120
|
+
].includes(String(row.phase)) || !Number.isSafeInteger(row.updatedAt) || Number(row.updatedAt) < 0 || row.failureCode !== null && knownFailureCode(row.failureCode) === null) throw new ListenStateError("Background supervisor state is invalid. Preserve it before retrying.");
|
|
121
|
+
return {
|
|
122
|
+
version: 1,
|
|
123
|
+
token: mailId(row.token),
|
|
124
|
+
pid: Number(row.pid),
|
|
125
|
+
identity: mailString(row.identity),
|
|
126
|
+
phase: row.phase,
|
|
127
|
+
updatedAt: Number(row.updatedAt),
|
|
128
|
+
failureCode: knownFailureCode(row.failureCode)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function readState(path) {
|
|
132
|
+
const value = readMailJson(path, 2048);
|
|
133
|
+
if (value === null) return null;
|
|
134
|
+
const row = mailObject(value, [
|
|
135
|
+
"version",
|
|
136
|
+
"token",
|
|
137
|
+
"pid",
|
|
138
|
+
"identity",
|
|
139
|
+
"phase",
|
|
140
|
+
"detached",
|
|
141
|
+
"configuration",
|
|
142
|
+
...typeof value === "object" && Object.hasOwn(value, "failureCode") ? ["failureCode"] : [],
|
|
143
|
+
"updatedAt"
|
|
144
|
+
]);
|
|
145
|
+
if (row.version !== 1 || !Number.isSafeInteger(row.pid) || Number(row.pid) < 1 || !phases.includes(row.phase) || typeof row.detached !== "boolean" || row.configuration !== null && typeof row.configuration !== "string" || row.failureCode !== void 0 && row.failureCode !== null && knownFailureCode(row.failureCode) === null || !Number.isSafeInteger(row.updatedAt) || Number(row.updatedAt) < 0) throw new ListenStateError("Background listener state is invalid. Preserve it before retrying.");
|
|
146
|
+
return {
|
|
147
|
+
version: 1,
|
|
148
|
+
token: mailId(row.token),
|
|
149
|
+
pid: Number(row.pid),
|
|
150
|
+
identity: mailString(row.identity),
|
|
151
|
+
phase: row.phase,
|
|
152
|
+
detached: row.detached,
|
|
153
|
+
configuration: configurationFingerprint(row.configuration === null ? void 0 : String(row.configuration)),
|
|
154
|
+
failureCode: knownFailureCode(row.failureCode),
|
|
155
|
+
updatedAt: Number(row.updatedAt)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function owner(state) {
|
|
159
|
+
const current = listenProcessIdentity(state.pid);
|
|
160
|
+
const matches = compareListenProcessIdentity(state.identity, current);
|
|
161
|
+
if (matches !== null) return matches ? "verified" : "gone";
|
|
162
|
+
try {
|
|
163
|
+
process.kill(state.pid, 0);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code === "ESRCH") return "gone";
|
|
166
|
+
}
|
|
167
|
+
return "unknown";
|
|
168
|
+
}
|
|
169
|
+
function status(state) {
|
|
170
|
+
if (!state) return {
|
|
171
|
+
phase: null,
|
|
172
|
+
pid: null,
|
|
173
|
+
detached: null,
|
|
174
|
+
healthy: false,
|
|
175
|
+
failureCode: null,
|
|
176
|
+
reason: "absent",
|
|
177
|
+
updatedAt: null
|
|
178
|
+
};
|
|
179
|
+
const age = Date.now() - state.updatedAt;
|
|
180
|
+
const ownership = owner(state);
|
|
181
|
+
const reason = state.phase === "stopped" || state.phase === "failed" ? state.phase : ownership === "gone" ? "exited" : ownership === "unknown" ? "unverifiable" : age < 0 || age >= STALE_AFTER_MS ? "stale" : null;
|
|
182
|
+
return {
|
|
183
|
+
phase: state.phase,
|
|
184
|
+
pid: state.pid,
|
|
185
|
+
detached: state.detached,
|
|
186
|
+
healthy: reason === null,
|
|
187
|
+
failureCode: state.failureCode,
|
|
188
|
+
reason,
|
|
189
|
+
updatedAt: state.updatedAt
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Absence means no managed record, not proof that an older foreground listener is absent. */
|
|
193
|
+
function backgroundListenStatus(target) {
|
|
194
|
+
const paths = files(target);
|
|
195
|
+
const worker = status(readState(paths.state));
|
|
196
|
+
const supervisor = readSupervisor(paths.supervisor);
|
|
197
|
+
if (!supervisor) return worker;
|
|
198
|
+
const ownership = owner(supervisor);
|
|
199
|
+
const age = Date.now() - supervisor.updatedAt;
|
|
200
|
+
const fresh = age >= 0 && age < STALE_AFTER_MS;
|
|
201
|
+
const active = supervisor.phase === "starting" || supervisor.phase === "running";
|
|
202
|
+
const reason = supervisor.phase === "failed" ? "failed" : supervisor.phase === "stopped" ? "stopped" : ownership === "gone" ? "exited" : ownership === "unknown" ? "unverifiable" : !fresh ? "stale" : worker.healthy ? null : "restarting";
|
|
203
|
+
return {
|
|
204
|
+
...worker,
|
|
205
|
+
phase: supervisor.phase === "failed" ? "failed" : reason === "restarting" ? "reconnecting" : worker.phase,
|
|
206
|
+
healthy: active && reason === null,
|
|
207
|
+
reason,
|
|
208
|
+
failureCode: supervisor.failureCode ?? worker.failureCode,
|
|
209
|
+
supervisorPid: supervisor.pid,
|
|
210
|
+
supervisorUpdatedAt: supervisor.updatedAt
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function stopToken(path) {
|
|
214
|
+
const value = readMailJson(path, 512);
|
|
215
|
+
if (value === null) return null;
|
|
216
|
+
return mailId(mailObject(value, ["token"]).token);
|
|
217
|
+
}
|
|
218
|
+
/** Signal only the verified generation through its private mailbox, never its PID. */
|
|
219
|
+
async function stopBackgroundListen(target, expectedToken) {
|
|
220
|
+
const paths = files(target);
|
|
221
|
+
const state = readState(paths.state);
|
|
222
|
+
const supervisor = readSupervisor(paths.supervisor);
|
|
223
|
+
if (!state && !supervisor) return status(null);
|
|
224
|
+
const token = supervisor?.token ?? state?.token;
|
|
225
|
+
if (!token) return status(null);
|
|
226
|
+
if (expectedToken !== void 0 && mailId(expectedToken) !== token) throw new ListenStateError("Background listener ownership changed; no stop was requested.");
|
|
227
|
+
if (state && !supervisor && (state.phase === "stopped" || state.phase === "failed" || owner(state) === "gone")) return status(state);
|
|
228
|
+
if (supervisor && owner(supervisor) === "unknown") throw new ListenStateError("Background supervisor ownership cannot be verified; no stop was requested.");
|
|
229
|
+
if (!supervisor && state && owner(state) !== "verified" && !state.identity.startsWith("darwin:")) throw new ListenStateError("Background listener ownership cannot be verified; no stop was requested.");
|
|
230
|
+
writeMailJson(paths.stop, { token });
|
|
231
|
+
const deadline = Date.now() + 5e3;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const current = readState(paths.state);
|
|
234
|
+
const currentSupervisor = readSupervisor(paths.supervisor);
|
|
235
|
+
if (currentSupervisor?.token === token && currentSupervisor.phase === "stopped") return backgroundListenStatus(target);
|
|
236
|
+
if (currentSupervisor?.token === token && currentSupervisor.phase === "failed") return backgroundListenStatus(target);
|
|
237
|
+
if (current && current.token !== token) return backgroundListenStatus(target);
|
|
238
|
+
if (!current && !currentSupervisor) return backgroundListenStatus(target);
|
|
239
|
+
if (!supervisor && current && (current.phase === "stopped" || current.phase === "failed" || owner(current) === "gone")) return backgroundListenStatus(target);
|
|
240
|
+
if (Date.now() >= deadline) return backgroundListenStatus(target);
|
|
241
|
+
await setTimeout$1(50);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function announce(token, phase, failureCode = null) {
|
|
245
|
+
try {
|
|
246
|
+
if (process.connected && process.send) process.send({
|
|
247
|
+
type: "primitive-listen-state",
|
|
248
|
+
token,
|
|
249
|
+
phase,
|
|
250
|
+
...phase === "failed" ? { failureCode } : {}
|
|
251
|
+
}, () => {});
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
/** Hold exclusive ownership across safe retries; the caller decides which errors are retryable. */
|
|
255
|
+
async function runBackgroundListen(options) {
|
|
256
|
+
verifyBackgroundListenTarget(options);
|
|
257
|
+
const token = mailId(options.token);
|
|
258
|
+
const configuration = configurationFingerprint(options.configuration);
|
|
259
|
+
const paths = files(options, true);
|
|
260
|
+
const identity = listenProcessIdentity(process.pid);
|
|
261
|
+
if (!identity) throw new ListenStateError("Listener process ownership cannot be verified.");
|
|
262
|
+
const release = acquireListenLock(paths.directory, "background-runtime");
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const cancel = () => controller.abort();
|
|
265
|
+
const state = {
|
|
266
|
+
version: 1,
|
|
267
|
+
token,
|
|
268
|
+
pid: process.pid,
|
|
269
|
+
identity,
|
|
270
|
+
phase: "starting",
|
|
271
|
+
detached: options.detached ?? false,
|
|
272
|
+
configuration,
|
|
273
|
+
failureCode: null,
|
|
274
|
+
updatedAt: Date.now()
|
|
275
|
+
};
|
|
276
|
+
let failure;
|
|
277
|
+
let timer;
|
|
278
|
+
let started = false;
|
|
279
|
+
const publish = (phase = state.phase) => {
|
|
280
|
+
const previous = readState(paths.state);
|
|
281
|
+
if (started && previous?.token !== token) throw new ListenStateError("Listener process ownership changed.");
|
|
282
|
+
state.phase = phase;
|
|
283
|
+
state.updatedAt = Date.now();
|
|
284
|
+
writeMailJson(paths.state, state);
|
|
285
|
+
started = true;
|
|
286
|
+
};
|
|
287
|
+
const tick = () => {
|
|
288
|
+
try {
|
|
289
|
+
if (stopToken(paths.stop) === token) cancel();
|
|
290
|
+
publish();
|
|
291
|
+
} catch (error) {
|
|
292
|
+
failure ??= { error };
|
|
293
|
+
cancel();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
297
|
+
process.on("SIGTERM", cancel);
|
|
298
|
+
process.on("SIGINT", cancel);
|
|
299
|
+
try {
|
|
300
|
+
if (options.signal?.aborted) cancel();
|
|
301
|
+
publish();
|
|
302
|
+
tick();
|
|
303
|
+
timer = setInterval(tick, options.heartbeatMs ?? 1e3);
|
|
304
|
+
let attempts = 0;
|
|
305
|
+
while (!controller.signal.aborted) {
|
|
306
|
+
const attempt = new AbortController();
|
|
307
|
+
const signal = AbortSignal.any([controller.signal, attempt.signal]);
|
|
308
|
+
try {
|
|
309
|
+
await options.run(signal, () => {
|
|
310
|
+
signal.throwIfAborted();
|
|
311
|
+
publish("receiving");
|
|
312
|
+
announce(token, "receiving");
|
|
313
|
+
});
|
|
314
|
+
break;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (controller.signal.aborted) {
|
|
317
|
+
if (error === signal.reason || error instanceof Error && error.name === "AbortError") break;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
if (!options.retryable(error)) throw error;
|
|
321
|
+
publish("reconnecting");
|
|
322
|
+
announce(token, "reconnecting");
|
|
323
|
+
} finally {
|
|
324
|
+
attempt.abort();
|
|
325
|
+
}
|
|
326
|
+
await setTimeout$1(Math.min((options.retryDelayMs ?? 1e3) * 2 ** Math.min(attempts++, 5), 3e4), void 0, { signal: controller.signal }).catch((error) => {
|
|
327
|
+
if (!controller.signal.aborted) throw error;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (failure) throw failure.error;
|
|
331
|
+
publish("stopped");
|
|
332
|
+
} catch (error) {
|
|
333
|
+
failure ??= { error };
|
|
334
|
+
try {
|
|
335
|
+
state.failureCode = knownFailureCode(options.failureCode?.(failure.error)) ?? "receiving-failed";
|
|
336
|
+
} catch {
|
|
337
|
+
state.failureCode = "receiving-failed";
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
if (readState(paths.state)?.token === token) publish("failed");
|
|
341
|
+
} catch {}
|
|
342
|
+
announce(token, "failed", state.failureCode);
|
|
343
|
+
} finally {
|
|
344
|
+
if (timer) clearInterval(timer);
|
|
345
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
346
|
+
process.off("SIGTERM", cancel);
|
|
347
|
+
process.off("SIGINT", cancel);
|
|
348
|
+
try {
|
|
349
|
+
if (process.env[SUPERVISOR_WORKER_ENV] !== token && stopToken(paths.stop) === token) removeMailFile(paths.stop);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
failure ??= { error };
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
release();
|
|
355
|
+
} catch (error) {
|
|
356
|
+
failure ??= { error };
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (failure) throw failure.error;
|
|
360
|
+
}
|
|
361
|
+
/** Supervise one exact listener generation until a stop or the restart budget is exhausted. */
|
|
362
|
+
async function runBackgroundListenSupervisor() {
|
|
363
|
+
const token = mailId(process.env[BACKGROUND_LISTEN_TOKEN_ENV]);
|
|
364
|
+
const expected = mailObject(JSON.parse(process.env["PRIMITIVE_LISTEN_BACKGROUND_TARGET"] ?? ""), [
|
|
365
|
+
"scope",
|
|
366
|
+
"threadId",
|
|
367
|
+
"configDir",
|
|
368
|
+
"configuration"
|
|
369
|
+
]);
|
|
370
|
+
const target = {
|
|
371
|
+
scope: mailString(expected.scope),
|
|
372
|
+
threadId: mailId(expected.threadId),
|
|
373
|
+
configDir: mailString(expected.configDir, 4096)
|
|
374
|
+
};
|
|
375
|
+
if (realpathSync(target.configDir) !== target.configDir) throw new ListenStateError("Background supervisor target changed.");
|
|
376
|
+
const argvValue = JSON.parse(process.env[SUPERVISOR_ARGV_ENV] ?? "");
|
|
377
|
+
if (!Array.isArray(argvValue) || argvValue.length < 1 || argvValue.some((part) => typeof part !== "string" || part.length > 4096)) throw new ListenStateError("Background supervisor command is invalid.");
|
|
378
|
+
const argv = argvValue;
|
|
379
|
+
const paths = files(target, true);
|
|
380
|
+
const release = acquireListenLock(paths.directory, "background-supervisor");
|
|
381
|
+
const identity = listenProcessIdentity(process.pid);
|
|
382
|
+
if (!identity) {
|
|
383
|
+
release();
|
|
384
|
+
throw new ListenStateError("Supervisor process ownership cannot be verified.");
|
|
385
|
+
}
|
|
386
|
+
const record = {
|
|
387
|
+
version: 1,
|
|
388
|
+
token,
|
|
389
|
+
pid: process.pid,
|
|
390
|
+
identity,
|
|
391
|
+
phase: "starting",
|
|
392
|
+
updatedAt: Date.now(),
|
|
393
|
+
failureCode: null
|
|
394
|
+
};
|
|
395
|
+
const publish = () => {
|
|
396
|
+
const prior = readSupervisor(paths.supervisor);
|
|
397
|
+
if (prior && prior.token !== token && owner(prior) !== "gone" && prior.phase !== "stopped" && prior.phase !== "failed") throw new ListenStateError("Another supervisor owns this listener.");
|
|
398
|
+
record.updatedAt = Date.now();
|
|
399
|
+
writeMailJson(paths.supervisor, record);
|
|
400
|
+
};
|
|
401
|
+
let child;
|
|
402
|
+
let stopped = false;
|
|
403
|
+
const cancel = () => {
|
|
404
|
+
stopped = true;
|
|
405
|
+
writeMailJson(paths.stop, { token });
|
|
406
|
+
child?.kill("SIGTERM");
|
|
407
|
+
};
|
|
408
|
+
process.on("SIGTERM", cancel);
|
|
409
|
+
process.on("SIGINT", cancel);
|
|
410
|
+
const heartbeat = setInterval(() => {
|
|
411
|
+
try {
|
|
412
|
+
if (stopToken(paths.stop) === token) {
|
|
413
|
+
stopped = true;
|
|
414
|
+
child?.kill("SIGTERM");
|
|
415
|
+
}
|
|
416
|
+
publish();
|
|
417
|
+
} catch {
|
|
418
|
+
cancel();
|
|
419
|
+
}
|
|
420
|
+
}, 1e3);
|
|
421
|
+
const failures = [];
|
|
422
|
+
try {
|
|
423
|
+
publish();
|
|
424
|
+
while (!stopped) {
|
|
425
|
+
if (stopToken(paths.stop) === token) break;
|
|
426
|
+
const env = {
|
|
427
|
+
...process.env,
|
|
428
|
+
[SUPERVISOR_WORKER_ENV]: token
|
|
429
|
+
};
|
|
430
|
+
delete env[SUPERVISOR_ENV];
|
|
431
|
+
delete env[SUPERVISOR_ARGV_ENV];
|
|
432
|
+
child = spawn(process.execPath, argv, {
|
|
433
|
+
env,
|
|
434
|
+
windowsHide: true,
|
|
435
|
+
stdio: [
|
|
436
|
+
"ignore",
|
|
437
|
+
"ignore",
|
|
438
|
+
"ignore",
|
|
439
|
+
"ipc"
|
|
440
|
+
]
|
|
441
|
+
});
|
|
442
|
+
let readyAt = null;
|
|
443
|
+
child.on("message", (message) => {
|
|
444
|
+
if (!message || typeof message !== "object") return;
|
|
445
|
+
const row = message;
|
|
446
|
+
if (row.type !== "primitive-listen-state" || row.token !== token) return;
|
|
447
|
+
if (row.phase === "receiving" && readyAt === null) readyAt = Date.now();
|
|
448
|
+
try {
|
|
449
|
+
if (process.connected) process.send?.(message, () => {});
|
|
450
|
+
} catch {}
|
|
451
|
+
});
|
|
452
|
+
record.phase = "running";
|
|
453
|
+
publish();
|
|
454
|
+
await new Promise((resolve) => {
|
|
455
|
+
child?.once("exit", () => resolve());
|
|
456
|
+
child?.once("error", () => resolve());
|
|
457
|
+
});
|
|
458
|
+
child = void 0;
|
|
459
|
+
if (stopped || stopToken(paths.stop) === token) break;
|
|
460
|
+
const worker = readState(paths.state);
|
|
461
|
+
if (worker?.token === token && worker.phase === "failed" && ["notification-outcome-unknown", "connection-changed"].includes(worker.failureCode ?? "")) {
|
|
462
|
+
record.phase = "failed";
|
|
463
|
+
record.failureCode = worker.failureCode;
|
|
464
|
+
publish();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (readyAt !== null && now - readyAt >= HEALTHY_RESET_MS) failures.length = 0;
|
|
469
|
+
failures.push(now);
|
|
470
|
+
while (failures.length && (failures[0] ?? now) < now - RESTART_WINDOW_MS) failures.shift();
|
|
471
|
+
if (failures.length >= MAX_FAILURES) {
|
|
472
|
+
record.phase = "failed";
|
|
473
|
+
record.failureCode = "restart-budget-exhausted";
|
|
474
|
+
publish();
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
record.phase = "starting";
|
|
478
|
+
publish();
|
|
479
|
+
const backoff = Math.min(1e3 * 2 ** (failures.length - 1), 3e4);
|
|
480
|
+
const end = Date.now() + backoff;
|
|
481
|
+
while (!stopped && Date.now() < end && stopToken(paths.stop) !== token) await setTimeout$1(Math.min(200, end - Date.now()));
|
|
482
|
+
}
|
|
483
|
+
record.phase = "stopped";
|
|
484
|
+
publish();
|
|
485
|
+
} catch (error) {
|
|
486
|
+
record.phase = "failed";
|
|
487
|
+
record.failureCode = "receiving-failed";
|
|
488
|
+
try {
|
|
489
|
+
publish();
|
|
490
|
+
} catch {}
|
|
491
|
+
throw error;
|
|
492
|
+
} finally {
|
|
493
|
+
clearInterval(heartbeat);
|
|
494
|
+
process.off("SIGTERM", cancel);
|
|
495
|
+
process.off("SIGINT", cancel);
|
|
496
|
+
child?.kill("SIGTERM");
|
|
497
|
+
if (stopToken(paths.stop) === token) removeMailFile(paths.stop);
|
|
498
|
+
if (record.phase === "stopped" && readState(paths.state) === null) removeMailFile(paths.supervisor);
|
|
499
|
+
release();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Ask one worker generation to stop and wait briefly until it has. */
|
|
503
|
+
async function stopOrphanedWorker(paths, token) {
|
|
504
|
+
writeMailJson(paths.stop, { token });
|
|
505
|
+
const deadline = Date.now() + 5e3;
|
|
506
|
+
for (;;) {
|
|
507
|
+
const current = readState(paths.state);
|
|
508
|
+
if (!current || current.token !== token || current.phase === "stopped" || current.phase === "failed" || owner(current) === "gone") return true;
|
|
509
|
+
if (Date.now() >= deadline) return false;
|
|
510
|
+
await setTimeout$1(50);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** Spawn the same CLI entrypoint. Overrides travel only in the child's environment. */
|
|
514
|
+
async function startBackgroundListen(options) {
|
|
515
|
+
const configuration = configurationFingerprint(options.configuration);
|
|
516
|
+
const paths = files(options, true);
|
|
517
|
+
const timeout = options.startupTimeoutMs ?? 15e3;
|
|
518
|
+
const deadline = Date.now() + timeout;
|
|
519
|
+
let release;
|
|
520
|
+
while (!release) {
|
|
521
|
+
options.signal?.throwIfAborted();
|
|
522
|
+
try {
|
|
523
|
+
release = acquireListenLock(paths.directory, "background-start");
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (!mailLockBusy(error) || Date.now() >= deadline) throw error;
|
|
526
|
+
await setTimeout$1(25, void 0, { signal: options.signal });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const prior = readState(paths.state);
|
|
531
|
+
const priorSupervisor = readSupervisor(paths.supervisor);
|
|
532
|
+
if (priorSupervisor && ["starting", "running"].includes(priorSupervisor.phase) && owner(priorSupervisor) !== "gone") {
|
|
533
|
+
const current = backgroundListenStatus(options);
|
|
534
|
+
if (!current.healthy) throw new ListenStateError("Existing supervisor ownership is stale or unverifiable. Inspect status before restarting.");
|
|
535
|
+
if (prior?.configuration !== configuration) throw new ListenStateError("Stop the existing listener before changing receiving options.");
|
|
536
|
+
return {
|
|
537
|
+
started: false,
|
|
538
|
+
status: current
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
if (prior && prior.phase !== "stopped" && prior.phase !== "failed" && owner(prior) !== "gone") {
|
|
542
|
+
const current = status(prior);
|
|
543
|
+
if (!current.healthy) throw new ListenStateError("Existing listener ownership is stale or unverifiable; the worker was retained. Inspect status or request a stop before restarting.");
|
|
544
|
+
if (!prior.detached) throw new ListenStateError("An existing foreground listener owns this session. Stop it before restarting with --background.");
|
|
545
|
+
if (prior.configuration !== configuration) throw new ListenStateError("Stop the existing listener before changing receiving options.");
|
|
546
|
+
if (!priorSupervisor) return {
|
|
547
|
+
started: false,
|
|
548
|
+
status: current
|
|
549
|
+
};
|
|
550
|
+
if (!await stopOrphanedWorker(paths, prior.token)) throw new ListenStateError("The existing background listener lost its supervisor and did not stop. Stop it, then start again.");
|
|
551
|
+
}
|
|
552
|
+
const token = randomUUID();
|
|
553
|
+
const env = {
|
|
554
|
+
...process.env,
|
|
555
|
+
...options.env,
|
|
556
|
+
[BACKGROUND_LISTEN_TOKEN_ENV]: token,
|
|
557
|
+
[BACKGROUND_LISTEN_TARGET_ENV]: JSON.stringify({
|
|
558
|
+
scope: mailString(options.scope),
|
|
559
|
+
threadId: mailId(options.threadId),
|
|
560
|
+
configDir: realpathSync(options.configDir),
|
|
561
|
+
configuration
|
|
562
|
+
})
|
|
563
|
+
};
|
|
564
|
+
const argv = [];
|
|
565
|
+
for (let index = 0; index < options.argv.length; index++) {
|
|
566
|
+
const arg = options.argv[index];
|
|
567
|
+
if (arg === void 0) continue;
|
|
568
|
+
if (arg === "--background" || arg.startsWith("--background=")) continue;
|
|
569
|
+
if (arg === "--api-key") {
|
|
570
|
+
const key = options.argv[++index];
|
|
571
|
+
if (key === void 0) throw new ListenStateError("An API key value is required.");
|
|
572
|
+
env.PRIMITIVE_API_KEY = key.trim();
|
|
573
|
+
} else if (arg.startsWith("--api-key=")) env.PRIMITIVE_API_KEY = arg.slice(10).trim();
|
|
574
|
+
else argv.push(arg);
|
|
575
|
+
}
|
|
576
|
+
if (!argv.length) throw new ListenStateError("The CLI entrypoint is required.");
|
|
577
|
+
env[SUPERVISOR_ENV] = "1";
|
|
578
|
+
env[SUPERVISOR_ARGV_ENV] = JSON.stringify(argv);
|
|
579
|
+
const child = spawn(process.execPath, [argv[0] ?? ""], {
|
|
580
|
+
env,
|
|
581
|
+
detached: true,
|
|
582
|
+
windowsHide: true,
|
|
583
|
+
stdio: [
|
|
584
|
+
"ignore",
|
|
585
|
+
"ignore",
|
|
586
|
+
"ignore",
|
|
587
|
+
"ipc"
|
|
588
|
+
]
|
|
589
|
+
});
|
|
590
|
+
let startupFailureCode = null;
|
|
591
|
+
try {
|
|
592
|
+
await new Promise((resolve, reject) => {
|
|
593
|
+
const timer = setTimeout(() => finish(new ListenStateError("Background listener startup timed out.")), timeout);
|
|
594
|
+
const abort = () => finish(new ListenStateError("Background listener startup was cancelled."));
|
|
595
|
+
const exit = () => finish(new ListenStateError("Background listener stopped before it became ready."));
|
|
596
|
+
const error = () => finish(new ListenStateError("Background listener could not be started."));
|
|
597
|
+
const message = (value) => {
|
|
598
|
+
if (!value || typeof value !== "object") return;
|
|
599
|
+
const next = value;
|
|
600
|
+
if (next.type !== "primitive-listen-state" || next.token !== token) return;
|
|
601
|
+
if (next.phase === "receiving" || next.phase === "reconnecting") finish();
|
|
602
|
+
else if (next.phase === "failed") {
|
|
603
|
+
startupFailureCode = knownFailureCode(next.failureCode);
|
|
604
|
+
error();
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const finish = (failure) => {
|
|
608
|
+
clearTimeout(timer);
|
|
609
|
+
options.signal?.removeEventListener("abort", abort);
|
|
610
|
+
child.off("exit", exit);
|
|
611
|
+
child.off("error", error);
|
|
612
|
+
child.off("message", message);
|
|
613
|
+
if (failure) reject(failure);
|
|
614
|
+
else resolve();
|
|
615
|
+
};
|
|
616
|
+
child.once("exit", exit);
|
|
617
|
+
child.once("error", error);
|
|
618
|
+
child.on("message", message);
|
|
619
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
620
|
+
if (options.signal?.aborted) abort();
|
|
621
|
+
});
|
|
622
|
+
const current = readState(paths.state);
|
|
623
|
+
const supervisor = readSupervisor(paths.supervisor);
|
|
624
|
+
if (current?.token !== token || supervisor?.token !== token || current.configuration !== configuration || !current.detached || !backgroundListenStatus(options).healthy) throw new ListenStateError("Background listener ownership changed during startup.");
|
|
625
|
+
return {
|
|
626
|
+
started: true,
|
|
627
|
+
status: backgroundListenStatus(options)
|
|
628
|
+
};
|
|
629
|
+
} catch {
|
|
630
|
+
writeMailJson(paths.stop, { token });
|
|
631
|
+
const until = Date.now() + 2e3;
|
|
632
|
+
while (Date.now() < until && child.exitCode === null && child.signalCode === null) await setTimeout$1(50);
|
|
633
|
+
const current = readState(paths.state);
|
|
634
|
+
const unconfirmed = current?.token === token && owner(current) !== "gone" && current.phase !== "stopped" && current.phase !== "failed" || child.exitCode === null && child.signalCode === null;
|
|
635
|
+
const code = startupFailureCode ?? (current?.token === token ? current.failureCode : null);
|
|
636
|
+
const guidance = code ? unconfirmed && code === "receiving-failed" ? "" : failureGuidance(code) : unconfirmed ? "" : "Run the same command without --background for details.";
|
|
637
|
+
throw new ListenStateError(`${unconfirmed ? "Background listener did not become ready; a stop was requested, but worker termination is unconfirmed. Inspect status before retrying." : "Background listener did not become ready and stopped."}${guidance ? ` ${guidance}` : ""}`);
|
|
638
|
+
} finally {
|
|
639
|
+
if (child.connected) child.disconnect();
|
|
640
|
+
child.unref();
|
|
641
|
+
}
|
|
642
|
+
} finally {
|
|
643
|
+
release();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
export { startBackgroundListen as a, runBackgroundListenSupervisor as i, backgroundListenToken as n, stopBackgroundListen as o, runBackgroundListen as r, verifyBackgroundListenTarget as s, backgroundListenStatus as t };
|