primcli 1.75.0 → 3.0.0-preview.10

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.
@@ -10,6 +10,7 @@ const BACKGROUND_LISTEN_TARGET_ENV = "PRIMITIVE_LISTEN_BACKGROUND_TARGET";
10
10
  const SUPERVISOR_ENV = "PRIMITIVE_LISTEN_SUPERVISOR";
11
11
  const SUPERVISOR_ARGV_ENV = "PRIMITIVE_LISTEN_SUPERVISOR_ARGV";
12
12
  const SUPERVISOR_WORKER_ENV = "PRIMITIVE_LISTEN_SUPERVISOR_WORKER";
13
+ const BACKGROUND_LISTEN_RELAUNCH_ENV = "PRIMITIVE_LISTEN_RELAUNCH_FOR";
13
14
  const STALE_AFTER_MS = 15e3;
14
15
  const RESTART_WINDOW_MS = 6e5;
15
16
  const HEALTHY_RESET_MS = 6e5;
@@ -38,6 +39,27 @@ const HOLDING_FAILURE_CODES = [
38
39
  "connection-changed",
39
40
  "unrecognized"
40
41
  ];
42
+ const startFailureReasons = [
43
+ "timed-out",
44
+ "cancelled",
45
+ "supervisor-exited",
46
+ "spawn-failed",
47
+ "worker-failed",
48
+ "ownership-changed",
49
+ "options-changed",
50
+ "existing-kept",
51
+ "unverifiable"
52
+ ];
53
+ /**
54
+ * A failure code as a worker reported it. One from a newer CLI keeps its name
55
+ * in supervisor.json, so that CLI reads its own code back; readers that do
56
+ * not know it see "unrecognized". Anything that is not a plain code name is
57
+ * "unrecognized" outright.
58
+ */
59
+ function reportedFailureCode(value) {
60
+ if (value === null || value === void 0) return null;
61
+ return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value) ? value : "unrecognized";
62
+ }
41
63
  function knownFailureCode(value) {
42
64
  return failureCodes.includes(value) ? value : null;
43
65
  }
@@ -83,8 +105,38 @@ function failureGuidance(code) {
83
105
  case "unrecognized": return "A newer Primitive CLI recorded this receiver's state. Inspect it with that version before restarting.";
84
106
  }
85
107
  }
108
+ function startFailureCause(reason) {
109
+ switch (reason) {
110
+ case "timed-out": return "the receiver was not ready before the startup timeout";
111
+ case "cancelled": return "the start was cancelled, or the receiver was stopped while it started";
112
+ case "supervisor-exited": return "its supervisor exited before the receiver was ready";
113
+ case "spawn-failed": return "its supervisor could not be started";
114
+ case "worker-failed": return "the receiver reported a failure while starting";
115
+ case "ownership-changed": return "another listener held this receiver, or its supervisor no longer did, when startup was checked";
116
+ case "options-changed": return "the receiver ran with options other than those requested";
117
+ case "existing-kept": return "the receiver already recorded for this session could not be verified or stopped, so it was left in place";
118
+ case "unverifiable": return "its records could not be read to confirm it";
119
+ case "unrecognized": return "its reason is one this version of the CLI does not recognize";
120
+ }
121
+ }
122
+ /**
123
+ * A failed start is history once the receiver is healthy and receiving. A
124
+ * healthy receiver that is still starting or reconnecting has not shown that
125
+ * the start which failed was recovered from.
126
+ */
127
+ function startFailureCurrent(status) {
128
+ return !(status.healthy && status.phase === "receiving");
129
+ }
86
130
  /** One sentence an owner or agent can act on; never raw error text. */
87
131
  function statusDetail(status) {
132
+ const base = receiverDetail(status);
133
+ const failure = status.lastStartFailure;
134
+ if (!failure || !startFailureCurrent(status)) return base;
135
+ const restart = status.reason === "stopped" || status.reason === "absent" ? " Start it again with the same listen command, or run it without --background for details." : "";
136
+ const note = `The last background start, at ${new Date(failure.at).toISOString()}, failed: ${startFailureCause(failure.reason)}.${restart}`;
137
+ return base ? `${base} ${note}` : note;
138
+ }
139
+ function receiverDetail(status) {
88
140
  if (status.healthy) return null;
89
141
  const guidance = status.failureCode ? failureGuidance(status.failureCode) : "";
90
142
  const withGuidance = (text) => guidance ? `${text} ${guidance}` : text;
@@ -103,6 +155,23 @@ function backgroundListenToken(env = process.env) {
103
155
  const value = env[BACKGROUND_LISTEN_TOKEN_ENV];
104
156
  return value === void 0 ? null : mailId(value);
105
157
  }
158
+ /**
159
+ * The fingerprint of the options a receiver runs with. A supervisor restarts
160
+ * its worker with the same command, also after the CLI was upgraded in place,
161
+ * so the value must not depend on the CLI version or anything else that
162
+ * changes between releases. Earlier releases included their version; a worker
163
+ * restarted by a supervisor of one of them hands its receiver over (see
164
+ * verifyBackgroundListenTarget).
165
+ */
166
+ function backgroundListenConfiguration(options) {
167
+ return createHash("sha256").update(JSON.stringify({
168
+ contacts: options.contacts,
169
+ contactRequests: options.contactRequests,
170
+ senders: [...options.senders].sort(),
171
+ socketPath: options.socketPath,
172
+ events: options.events?.map((event) => event.trim()).sort()
173
+ })).digest("hex");
174
+ }
106
175
  function configurationFingerprint(value) {
107
176
  if (value === void 0) return null;
108
177
  if (!/^[a-f0-9]{64}$/i.test(value)) throw new ListenStateError("Listener configuration fingerprint is invalid.");
@@ -110,7 +179,10 @@ function configurationFingerprint(value) {
110
179
  }
111
180
  /** Verify the parent's selection before a child reads or mutates listener state. */
112
181
  function verifyBackgroundListenTarget(target, env = process.env) {
113
- if (backgroundListenToken(env) === null) return;
182
+ const token = backgroundListenToken(env);
183
+ if (token === null) return;
184
+ let changedConnection = true;
185
+ let changedOptions = false;
114
186
  try {
115
187
  const value = JSON.parse(env["PRIMITIVE_LISTEN_BACKGROUND_TARGET"] ?? "");
116
188
  const expected = mailObject(value, [
@@ -122,11 +194,34 @@ function verifyBackgroundListenTarget(target, env = process.env) {
122
194
  if (expected.configuration !== null && typeof expected.configuration !== "string") throw new Error("Invalid configuration fingerprint");
123
195
  const configuration = configurationFingerprint(expected.configuration === null ? void 0 : String(expected.configuration));
124
196
  const configDir = mailString(expected.configDir, 4096);
125
- 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");
197
+ if (mailString(expected.scope) !== mailString(target.scope) || mailId(expected.threadId) !== mailId(target.threadId)) throw new Error("Changed listener target");
198
+ let resolved;
199
+ try {
200
+ resolved = [realpathSync(configDir), realpathSync(target.configDir)];
201
+ } catch (error) {
202
+ if (storageFailureCode(error) !== null) changedConnection = false;
203
+ throw error;
204
+ }
205
+ if (resolved.some((path) => path !== configDir)) throw new Error("Changed listener target");
206
+ changedConnection = false;
207
+ changedOptions = configuration !== configurationFingerprint(target.configuration);
208
+ if (changedOptions) throw new Error("Changed listener options");
126
209
  } catch {
210
+ if (changedConnection && env === process.env) announce(token, "failed", "connection-changed");
211
+ if (changedOptions && env === process.env && env[SUPERVISOR_WORKER_ENV] === token) handOverReceiver(target, token);
127
212
  throw new ListenStateError("Background listener target changed or its parent expectation is invalid. No listener was started.");
128
213
  }
129
214
  }
215
+ /**
216
+ * Relaunch this worker's command with --background to replace the supervisor
217
+ * generation `token`. The claim allows one handover per restart interval: the
218
+ * supervisor may restart this worker again before the relaunch stops it.
219
+ */
220
+ function handOverReceiver(target, token) {
221
+ try {
222
+ if (claimBackgroundListenRestart(target)) relaunchSupervisor(token);
223
+ } catch {}
224
+ }
130
225
  function configDirectory(path, create) {
131
226
  let info;
132
227
  try {
@@ -157,9 +252,51 @@ function files(target, create = false) {
157
252
  directory,
158
253
  state: join(directory, "state.json"),
159
254
  stop: join(directory, "stop.json"),
160
- supervisor: join(directory, "supervisor.json")
255
+ supervisor: join(directory, "supervisor.json"),
256
+ startFailure: join(directory, "start-failure.json"),
257
+ ownerStop: join(directory, "owner-stop.json")
161
258
  };
162
259
  }
260
+ /** A diagnostic only: an unreadable record is reported as none, never as an error. */
261
+ function readStartFailure(path) {
262
+ try {
263
+ const value = readMailJson(path, 1024);
264
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
265
+ const row = value;
266
+ if (row.version !== 1 || !Number.isSafeInteger(row.at) || Number(row.at) < 0 || Number(row.at) > 864e13 || typeof row.reason !== "string" || row.failureCode !== null && typeof row.failureCode !== "string") return null;
267
+ return {
268
+ at: Number(row.at),
269
+ reason: startFailureReasons.includes(row.reason) ? row.reason : "unrecognized",
270
+ failureCode: savedFailureCode(row.failureCode)
271
+ };
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+ function recordStartFailure(paths, reason, failureCode) {
277
+ try {
278
+ writeMailJson(paths.startFailure, {
279
+ version: 1,
280
+ at: Date.now(),
281
+ reason,
282
+ failureCode
283
+ });
284
+ } catch {}
285
+ }
286
+ /** Whether an owner asked generation `token` to stop; unreadable means no. */
287
+ function ownerStopped(paths, token) {
288
+ try {
289
+ const value = readMailJson(paths.ownerStop, 512);
290
+ return !!value && typeof value === "object" && !Array.isArray(value) && typeof value.token === "string" && String(value.token).toLowerCase() === token;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+ function clearStartFailure(paths) {
296
+ try {
297
+ removeMailFile(paths.startFailure);
298
+ } catch {}
299
+ }
163
300
  function readSupervisor(path) {
164
301
  const value = readMailJson(path, 2048);
165
302
  if (value === null) return null;
@@ -264,7 +401,15 @@ function backgroundListenStatus(target) {
264
401
  const paths = files(target);
265
402
  const worker = status(readState(paths.state));
266
403
  const supervisor = readSupervisor(paths.supervisor);
267
- if (!supervisor) return worker;
404
+ const saved = readStartFailure(paths.startFailure);
405
+ if (!supervisor) return saved && startFailureCurrent(worker) ? {
406
+ ...worker,
407
+ lastStartFailure: saved,
408
+ detail: statusDetail({
409
+ ...worker,
410
+ lastStartFailure: saved
411
+ })
412
+ } : worker;
268
413
  const ownership = owner(supervisor);
269
414
  const age = Date.now() - supervisor.updatedAt;
270
415
  const fresh = age >= 0 && age < STALE_AFTER_MS;
@@ -272,9 +417,14 @@ function backgroundListenStatus(target) {
272
417
  const reason = supervisor.phase === "failed" ? "failed" : supervisor.phase === "stopped" ? "stopped" : ownership === "gone" ? "exited" : ownership === "unknown" ? "unverifiable" : !fresh ? "stale" : worker.healthy ? null : "restarting";
273
418
  const healthy = active && reason === null;
274
419
  const failureCode = healthy ? null : supervisor.failureCode ?? worker.failureCode;
420
+ const phase = supervisor.phase === "failed" ? "failed" : reason === "restarting" ? "reconnecting" : worker.phase;
421
+ const lastStartFailure = saved && startFailureCurrent({
422
+ healthy,
423
+ phase
424
+ }) ? saved : null;
275
425
  return {
276
426
  ...worker,
277
- phase: supervisor.phase === "failed" ? "failed" : reason === "restarting" ? "reconnecting" : worker.phase,
427
+ phase,
278
428
  healthy,
279
429
  reason,
280
430
  failureCode,
@@ -283,8 +433,11 @@ function backgroundListenStatus(target) {
283
433
  detail: statusDetail({
284
434
  healthy,
285
435
  reason,
286
- failureCode
287
- })
436
+ failureCode,
437
+ phase,
438
+ lastStartFailure
439
+ }),
440
+ ...lastStartFailure ? { lastStartFailure } : {}
288
441
  };
289
442
  }
290
443
  /**
@@ -328,6 +481,10 @@ async function stopBackgroundListen(target, expectedToken) {
328
481
  if (state && !supervisor && (state.phase === "stopped" || state.phase === "failed" || owner(state) === "gone")) return status(state);
329
482
  if (supervisor && owner(supervisor) === "unknown") throw new ListenStateError("Background supervisor ownership cannot be verified; no stop was requested.");
330
483
  if (!supervisor && state && owner(state) !== "verified" && !state.identity.startsWith("darwin:")) throw new ListenStateError("Background listener ownership cannot be verified; no stop was requested.");
484
+ writeMailJson(paths.ownerStop, {
485
+ version: 1,
486
+ token
487
+ });
331
488
  writeMailJson(paths.stop, { token });
332
489
  const deadline = Date.now() + 5e3;
333
490
  for (;;) {
@@ -399,7 +556,8 @@ async function runBackgroundListen(options) {
399
556
  return;
400
557
  }
401
558
  relaunched = true;
402
- (options.relaunchSupervisor ?? relaunchSupervisor)();
559
+ if (options.relaunchSupervisor) options.relaunchSupervisor();
560
+ else relaunchSupervisor(token);
403
561
  };
404
562
  const tick = () => {
405
563
  try {
@@ -483,8 +641,11 @@ async function runBackgroundListen(options) {
483
641
  if (failure) throw failure.error;
484
642
  }
485
643
  /** Relaunch this worker's own command detached, with --background restored. */
486
- function relaunchSupervisor() {
487
- const env = { ...process.env };
644
+ function relaunchSupervisor(token) {
645
+ const env = {
646
+ ...process.env,
647
+ [BACKGROUND_LISTEN_RELAUNCH_ENV]: token
648
+ };
488
649
  delete env[BACKGROUND_LISTEN_TOKEN_ENV];
489
650
  delete env[BACKGROUND_LISTEN_TARGET_ENV];
490
651
  delete env[SUPERVISOR_WORKER_ENV];
@@ -580,6 +741,7 @@ async function runBackgroundListenSupervisor(options = {}) {
580
741
  };
581
742
  process.on("uncaughtException", crashed);
582
743
  process.on("unhandledRejection", crashed);
744
+ let workerReceiving = false;
583
745
  const heartbeat = setInterval(() => {
584
746
  try {
585
747
  if (stopRequested()) {
@@ -587,6 +749,10 @@ async function runBackgroundListenSupervisor(options = {}) {
587
749
  child?.kill("SIGTERM");
588
750
  }
589
751
  tryPublish();
752
+ if (workerReceiving) try {
753
+ const worker = readState(paths.state);
754
+ if (worker?.token === token && worker.phase === "receiving" && status(worker).healthy) clearStartFailure(paths);
755
+ } catch {}
590
756
  } catch {
591
757
  cancel();
592
758
  }
@@ -618,14 +784,16 @@ async function runBackgroundListenSupervisor(options = {}) {
618
784
  if (!message || typeof message !== "object") return;
619
785
  const row = message;
620
786
  if (row.type !== "primitive-listen-state" || row.token !== token) return;
787
+ if (typeof row.phase === "string") workerReceiving = row.phase === "receiving";
621
788
  if (row.phase === "receiving") {
622
789
  if (readyAt === null) readyAt = Date.now();
790
+ clearStartFailure(paths);
623
791
  if (record.failureCode !== null) {
624
792
  record.failureCode = null;
625
793
  tryPublish();
626
794
  }
627
795
  }
628
- if (row.phase === "failed") reported = knownFailureCode(row.failureCode) ?? "receiving-failed";
796
+ if (row.phase === "failed") reported = reportedFailureCode(row.failureCode) ?? "receiving-failed";
629
797
  try {
630
798
  if (process.connected) process.send?.(message, () => {});
631
799
  } catch {}
@@ -643,6 +811,7 @@ async function runBackgroundListenSupervisor(options = {}) {
643
811
  }));
644
812
  });
645
813
  child = void 0;
814
+ workerReceiving = false;
646
815
  if (stopped || stopRequested()) break;
647
816
  let worker = null;
648
817
  try {
@@ -650,7 +819,8 @@ async function runBackgroundListenSupervisor(options = {}) {
650
819
  } catch {}
651
820
  const recorded = worker?.token === token && worker.phase === "failed" ? worker.failureCode : null;
652
821
  const failureCode = reported ?? recorded ?? (exit.code === 0 && exit.signal === null ? "receiving-failed" : "crashed");
653
- if (HOLDING_FAILURE_CODES.includes(failureCode)) {
822
+ const known = knownFailureCode(failureCode);
823
+ if (known === null || HOLDING_FAILURE_CODES.includes(known)) {
654
824
  record.phase = "failed";
655
825
  record.failureCode = failureCode;
656
826
  tryPublish();
@@ -702,6 +872,59 @@ async function stopOrphanedWorker(paths, token) {
702
872
  await setTimeout$1(50);
703
873
  }
704
874
  }
875
+ /**
876
+ * Prepare a start that a worker relaunched for its own generation `token`:
877
+ * its supervisor was lost, or runs another CLI build. Nothing starts once the
878
+ * owner stopped that generation, or while its supervisor is stopped or holds
879
+ * a failure. A supervisor of that generation that is alive while its
880
+ * receiver is not healthy is stopped first, and this waits until its process
881
+ * has exited, so its cleanup cannot touch the replacement. Anything else is
882
+ * left to the ordinary start that follows.
883
+ */
884
+ async function prepareRelaunch(paths, target, token, timeout, signal) {
885
+ const supervisor = readSupervisor(paths.supervisor);
886
+ if (ownerStopped(paths, token) || supervisor?.token === token && (supervisor.phase === "stopped" || supervisor.phase === "failed" && supervisor.failureCode !== "crashed")) throw relaunchCancelled();
887
+ if (supervisor?.token !== token || supervisor.phase !== "starting" && supervisor.phase !== "running" || owner(supervisor) === "gone" || backgroundListenStatus(target).healthy) return;
888
+ writeMailJson(paths.stop, { token });
889
+ const deadline = Date.now() + timeout;
890
+ let exited = false;
891
+ try {
892
+ while (Date.now() < deadline) {
893
+ if (owner(supervisor) === "gone") {
894
+ exited = true;
895
+ return;
896
+ }
897
+ await setTimeout$1(50, void 0, { signal });
898
+ }
899
+ } finally {
900
+ if (!exited) try {
901
+ if (stopToken(paths.stop) === token && !ownerStopped(paths, token)) removeMailFile(paths.stop);
902
+ } catch {}
903
+ }
904
+ recordStartFailure(paths, "existing-kept", null);
905
+ throw new ListenStateError("The supervisor this relaunch was to replace did not stop, so it was left running. Stop it, then start again.");
906
+ }
907
+ /**
908
+ * A start refused because a healthy receiver runs with another options
909
+ * fingerprint. Earlier releases put their version in it, so a receiver one
910
+ * of them started differs even when the command is the same. From here the
911
+ * two causes look alike, and stopping it is the remedy for both. The stop
912
+ * must reach the same connection, so the flags that chose it are named, never
913
+ * their values.
914
+ */
915
+ function keptWithOtherOptions(threadId, argv) {
916
+ const given = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
917
+ const stop = [
918
+ `primitive listen --notify-session ${threadId}`,
919
+ ...given("--api-key") ? ["--api-key <the same key>"] : [],
920
+ ...given("--api-base-url") ? ["--api-base-url <the same URL>"] : [],
921
+ "--stop"
922
+ ].join(" ");
923
+ return new ListenStateError(`A background listener for this session is still running, started by an older version of the CLI or with different receiving options, so it was kept. To restart it with this command, run \`${stop}\` with the same profile and environment, then run this command again.`);
924
+ }
925
+ function relaunchCancelled() {
926
+ return new ListenStateError("The receiver this relaunch was for was stopped or holds a failure, so nothing was started.");
927
+ }
705
928
  /** Spawn the same CLI entrypoint. Overrides travel only in the child's environment. */
706
929
  async function startBackgroundListen(options) {
707
930
  const configuration = configurationFingerprint(options.configuration);
@@ -719,12 +942,19 @@ async function startBackgroundListen(options) {
719
942
  }
720
943
  }
721
944
  try {
945
+ const relaunchValue = process.env[BACKGROUND_LISTEN_RELAUNCH_ENV];
946
+ const relaunchFor = relaunchValue === void 0 ? null : mailId(relaunchValue);
947
+ if (relaunchFor !== null) await prepareRelaunch(paths, options, relaunchFor, timeout, options.signal);
722
948
  const prior = readState(paths.state);
723
949
  const priorSupervisor = readSupervisor(paths.supervisor);
724
950
  if (priorSupervisor && ["starting", "running"].includes(priorSupervisor.phase) && owner(priorSupervisor) !== "gone") {
725
951
  const current = backgroundListenStatus(options);
726
- if (!current.healthy) throw new ListenStateError("Existing supervisor ownership is stale or unverifiable. Inspect status before restarting.");
727
- if (prior?.configuration !== configuration) throw new ListenStateError("Stop the existing listener before changing receiving options.");
952
+ if (!current.healthy) {
953
+ if (current.reason !== "restarting") recordStartFailure(paths, "existing-kept", current.failureCode);
954
+ throw new ListenStateError("Existing supervisor ownership is stale or unverifiable. Inspect status before restarting.");
955
+ }
956
+ if (prior?.configuration !== configuration) throw keptWithOtherOptions(options.threadId, options.argv);
957
+ if (!startFailureCurrent(current)) clearStartFailure(paths);
728
958
  return {
729
959
  started: false,
730
960
  status: current
@@ -732,17 +962,25 @@ async function startBackgroundListen(options) {
732
962
  }
733
963
  if (prior && prior.phase !== "stopped" && prior.phase !== "failed" && owner(prior) !== "gone") {
734
964
  const current = status(prior);
735
- 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.");
965
+ if (!current.healthy) {
966
+ recordStartFailure(paths, "existing-kept", current.failureCode);
967
+ throw new ListenStateError("Existing listener ownership is stale or unverifiable; the worker was retained. Inspect status or request a stop before restarting.");
968
+ }
736
969
  if (!prior.detached) throw new ListenStateError("An existing foreground listener owns this session. Stop it before restarting with --background.");
737
970
  if (!priorSupervisor) {
738
- if (prior.configuration !== configuration) throw new ListenStateError("Stop the existing listener before changing receiving options.");
971
+ if (prior.configuration !== configuration) throw keptWithOtherOptions(options.threadId, options.argv);
972
+ if (!startFailureCurrent(current)) clearStartFailure(paths);
739
973
  return {
740
974
  started: false,
741
975
  status: current
742
976
  };
743
977
  }
744
- 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.");
978
+ if (!await stopOrphanedWorker(paths, prior.token)) {
979
+ recordStartFailure(paths, "existing-kept", null);
980
+ throw new ListenStateError("The existing background listener lost its supervisor and did not stop. Stop it, then start again.");
981
+ }
745
982
  }
983
+ if (relaunchFor !== null && ownerStopped(paths, relaunchFor)) throw relaunchCancelled();
746
984
  const token = randomUUID();
747
985
  const env = {
748
986
  ...process.env,
@@ -755,6 +993,7 @@ async function startBackgroundListen(options) {
755
993
  configuration
756
994
  })
757
995
  };
996
+ delete env[BACKGROUND_LISTEN_RELAUNCH_ENV];
758
997
  const argv = [];
759
998
  for (let index = 0; index < options.argv.length; index++) {
760
999
  const arg = options.argv[index];
@@ -782,30 +1021,49 @@ async function startBackgroundListen(options) {
782
1021
  ]
783
1022
  });
784
1023
  let startupFailureCode = null;
1024
+ let supervisorFailureCode = null;
1025
+ let decision = "unverifiable";
1026
+ let keptReason = "timed-out";
1027
+ let outcome = null;
1028
+ const holds = (supervisor) => child.exitCode === null && child.signalCode === null && supervisor?.token === token && supervisor.pid === child.pid && (supervisor.phase === "starting" || supervisor.phase === "running") && owner(supervisor) !== "gone";
1029
+ const confirm = (waited) => {
1030
+ const current = readState(paths.state);
1031
+ if (current && current.token !== token && current.phase !== "stopped" && current.phase !== "failed" && owner(current) !== "gone") return "ownership-changed";
1032
+ const mine = current?.token === token ? current : null;
1033
+ if (mine && (mine.configuration !== configuration || !mine.detached)) return "options-changed";
1034
+ const supervisor = readSupervisor(paths.supervisor);
1035
+ const own = supervisor?.token === token ? supervisor : null;
1036
+ supervisorFailureCode = own?.failureCode ?? null;
1037
+ if (mine && own && (waited === "ready" || mine.phase === "receiving" || mine.phase === "reconnecting") && backgroundListenStatus(options).healthy) return "started";
1038
+ if (holds(supervisor)) return waited === "ready" ? "started" : "kept";
1039
+ if (own?.phase === "failed") return own.failureCode === "crashed" ? "supervisor-exited" : "worker-failed";
1040
+ if (own?.phase === "stopped") return "cancelled";
1041
+ if (child.exitCode !== null || child.signalCode !== null) return "supervisor-exited";
1042
+ return waited === "ready" ? "ownership-changed" : "timed-out";
1043
+ };
785
1044
  try {
786
- await new Promise((resolve, reject) => {
787
- const timer = setTimeout(() => finish(new ListenStateError("Background listener startup timed out.")), timeout);
788
- const abort = () => finish(new ListenStateError("Background listener startup was cancelled."));
789
- const exit = () => finish(new ListenStateError("Background listener stopped before it became ready."));
790
- const error = () => finish(new ListenStateError("Background listener could not be started."));
1045
+ const waited = await new Promise((resolve) => {
1046
+ const timer = setTimeout(() => finish("timed-out"), timeout);
1047
+ const abort = () => finish("cancelled");
1048
+ const exit = () => finish("supervisor-exited");
1049
+ const error = () => finish("spawn-failed");
791
1050
  const message = (value) => {
792
1051
  if (!value || typeof value !== "object") return;
793
1052
  const next = value;
794
1053
  if (next.type !== "primitive-listen-state" || next.token !== token) return;
795
- if (next.phase === "receiving" || next.phase === "reconnecting") finish();
1054
+ if (next.phase === "receiving" || next.phase === "reconnecting") finish("ready");
796
1055
  else if (next.phase === "failed") {
797
1056
  startupFailureCode = knownFailureCode(next.failureCode);
798
- error();
1057
+ finish("worker-failed");
799
1058
  }
800
1059
  };
801
- const finish = (failure) => {
1060
+ const finish = (outcome) => {
802
1061
  clearTimeout(timer);
803
1062
  options.signal?.removeEventListener("abort", abort);
804
1063
  child.off("exit", exit);
805
1064
  child.off("error", error);
806
1065
  child.off("message", message);
807
- if (failure) reject(failure);
808
- else resolve();
1066
+ resolve(outcome);
809
1067
  };
810
1068
  child.once("exit", exit);
811
1069
  child.once("error", error);
@@ -813,20 +1071,49 @@ async function startBackgroundListen(options) {
813
1071
  options.signal?.addEventListener("abort", abort, { once: true });
814
1072
  if (options.signal?.aborted) abort();
815
1073
  });
816
- const current = readState(paths.state);
817
- const supervisor = readSupervisor(paths.supervisor);
818
- if (current?.token !== token || supervisor?.token !== token || current.configuration !== configuration || !current.detached || !backgroundListenStatus(options).healthy) throw new ListenStateError("Background listener ownership changed during startup.");
819
- return {
820
- started: true,
821
- status: backgroundListenStatus(options)
822
- };
1074
+ outcome = waited;
1075
+ if (waited === "timed-out") decision = "timed-out";
1076
+ if (waited === "ready") await options.beforeStartupCheck?.();
1077
+ decision = waited === "ready" || waited === "timed-out" ? confirm(waited) : waited;
823
1078
  } catch {
1079
+ if ((outcome === "ready" || outcome === "timed-out") && child.exitCode === null && child.signalCode === null) {
1080
+ decision = "kept";
1081
+ keptReason = "unverifiable";
1082
+ }
1083
+ }
1084
+ try {
1085
+ if (decision === "started") {
1086
+ clearStartFailure(paths);
1087
+ return {
1088
+ started: true,
1089
+ status: backgroundListenStatus(options)
1090
+ };
1091
+ }
1092
+ if (decision === "kept") {
1093
+ recordStartFailure(paths, keptReason, supervisorFailureCode);
1094
+ try {
1095
+ const now = readState(paths.state);
1096
+ if (now?.token === token && now.phase === "receiving") {
1097
+ clearStartFailure(paths);
1098
+ return {
1099
+ started: true,
1100
+ status: backgroundListenStatus(options)
1101
+ };
1102
+ }
1103
+ } catch {}
1104
+ const guidance = supervisorFailureCode ? ` ${failureGuidance(supervisorFailureCode)}` : "";
1105
+ throw new ListenStateError(`${keptReason === "timed-out" ? "Background listener was not ready before the startup timeout." : "Background listener startup could not be confirmed because its records could not be read."} Its supervisor is still running and keeps the receiver, restarting it if it exits, so it was not stopped. Inspect status, or stop it before starting again.${guidance}`);
1106
+ }
824
1107
  writeMailJson(paths.stop, { token });
825
1108
  const until = Date.now() + 2e3;
826
1109
  while (Date.now() < until && child.exitCode === null && child.signalCode === null) await setTimeout$1(50);
827
- const current = readState(paths.state);
1110
+ let current = null;
1111
+ try {
1112
+ current = readState(paths.state);
1113
+ } catch {}
828
1114
  const unconfirmed = current?.token === token && owner(current) !== "gone" && current.phase !== "stopped" && current.phase !== "failed" || child.exitCode === null && child.signalCode === null;
829
1115
  const code = startupFailureCode ?? (current?.token === token ? current.failureCode : null);
1116
+ recordStartFailure(paths, decision, code ?? supervisorFailureCode);
830
1117
  const guidance = code ? unconfirmed && code === "receiving-failed" ? "" : failureGuidance(code) : unconfirmed ? "" : "Run the same command without --background for details.";
831
1118
  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}` : ""}`);
832
1119
  } finally {
@@ -837,4 +1124,4 @@ async function startBackgroundListen(options) {
837
1124
  release();
838
1125
  }
839
1126
  }
840
- export { claimBackgroundListenRestart as a, startBackgroundListen as c, backgroundListenToken as i, stopBackgroundListen as l, backgroundListenRestartable as n, runBackgroundListen as o, backgroundListenStatus as r, runBackgroundListenSupervisor as s, BACKGROUND_LISTEN_TOKEN_ENV as t, verifyBackgroundListenTarget as u };
1127
+ export { backgroundListenStatus as a, runBackgroundListen as c, stopBackgroundListen as d, verifyBackgroundListenTarget as f, backgroundListenRestartable as i, runBackgroundListenSupervisor as l, BACKGROUND_LISTEN_TOKEN_ENV as n, backgroundListenToken as o, backgroundListenConfiguration as r, claimBackgroundListenRestart as s, BACKGROUND_LISTEN_RELAUNCH_ENV as t, startBackgroundListen as u };