@zswarm/core 0.2.1 → 0.2.3

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.
@@ -11,6 +11,10 @@ const DEFAULT_DELAYS = [0, 30, 60, 120, 300, 600, 900, 1500];
11
11
  /** Consecutive matches of the done regex without a passing check before UNCONFIRMED. */
12
12
  const CONFIRM_TRIES = 3;
13
13
  const SERVE_DOWN_TRIES = 3;
14
+ /** Consecutive identical failing checks before CHECK_BROKEN: a check that can never pass. */
15
+ const CHECK_BROKEN_TRIES = 10;
16
+ /** How long a relay may outlive `--timeout-min` when `--max-age` is not given. */
17
+ const DEFAULT_MAX_AGE_EXTRA_MIN = 60;
14
18
  /** A wait that only finds `seen` lines returns at once; poll no faster than this. */
15
19
  const STALE_MATCH_POLL_MS = 15_000;
16
20
  export function relaysDir(env = process.env) {
@@ -78,6 +82,9 @@ export function fillMessage(template, fields) {
78
82
  const DEFAULT_TEMPLATE = "RELAY {id} STATUS {status} pane {pane}\n{output}{note}\n";
79
83
  const NOTES = {
80
84
  TIMEOUT: "no done line or passing check before the deadline; dump the pane once, do not resend blindly.",
85
+ MAX_AGE: "the relay reached --max-age without its done line; check the target pane and --done before resending.",
86
+ CHECK_BROKEN: "the check script failed the same way ten times in a row; fix the check (or --done) before resending.",
87
+ CANCELLED: "the relay was cancelled before it finished; nothing was pasted.",
81
88
  PANE_DIED: "the target pane is gone or exited; check the crew (zswarm crew status) before resending.",
82
89
  SERVE_DOWN: "serve was unreachable for three checks in a row; run zswarm doctor against it.",
83
90
  UNCONFIRMED: "the pane printed the done line but the check never passed; dump the pane once for the error.",
@@ -130,12 +137,20 @@ export async function relayLoop(dir, deps = {}) {
130
137
  ZSWARM_RELAY_PANE: cfg.to,
131
138
  ZSWARM_RELAY_DIR: dir,
132
139
  });
133
- const deadline = now() + cfg.timeoutMin * 60_000;
140
+ // The timeout is the normal end; --max-age (default timeout + 60 min) is the
141
+ // hard cap that still ends the relay when a wait or the serve-down backoff
142
+ // overruns the timeout.
143
+ const startedAtMs = now();
144
+ const timeoutAt = startedAtMs + cfg.timeoutMin * 60_000;
145
+ const maxAgeAt = startedAtMs + (cfg.maxAgeMin ?? cfg.timeoutMin + DEFAULT_MAX_AGE_EXTRA_MIN) * 60_000;
146
+ const deadline = Math.min(timeoutAt, maxAgeAt);
134
147
  let status = "TIMEOUT";
135
148
  let output = "";
136
149
  let match = "";
137
150
  let confirmTries = 0;
138
151
  let serveDown = 0;
152
+ let lastCheckFail;
153
+ let sameCheckFails = 0;
139
154
  log(`relay start id=${cfg.id} to=${cfg.to} serve=${cfg.serve} done=${cfg.done}`);
140
155
  while (now() < deadline) {
141
156
  const started = now();
@@ -163,6 +178,20 @@ export async function relayLoop(dir, deps = {}) {
163
178
  output = r.stdout;
164
179
  break;
165
180
  }
181
+ // The same failure over and over means the check can never pass for this
182
+ // pane: end as CHECK_BROKEN rather than polling to the timeout.
183
+ const sig = `${r.status}:${r.stdout}`;
184
+ if (sig === lastCheckFail)
185
+ sameCheckFails += 1;
186
+ else {
187
+ lastCheckFail = sig;
188
+ sameCheckFails = 1;
189
+ }
190
+ if (sameCheckFails >= CHECK_BROKEN_TRIES) {
191
+ status = "CHECK_BROKEN";
192
+ output = r.stdout;
193
+ break;
194
+ }
166
195
  }
167
196
  else {
168
197
  log(`wait ok=${res.ok} matched=${matched}${stale ? " (seen before start)" : ""}`);
@@ -207,6 +236,9 @@ export async function relayLoop(dir, deps = {}) {
207
236
  if (matched)
208
237
  await sleep(30_000); // the done line stays on screen; give the check time
209
238
  }
239
+ if (status === "TIMEOUT" && maxAgeAt <= timeoutAt && now() >= maxAgeAt)
240
+ status = "MAX_AGE";
241
+ writeFinalStatus(dir, status);
210
242
  let body;
211
243
  if (status !== "DONE" && check) {
212
244
  const r = runCheck(check, checkEnv(status, match));
@@ -269,6 +301,18 @@ function updateConfig(dir, patch) {
269
301
  const cfg = { ...JSON.parse(readFileSync(path, "utf8")), ...patch };
270
302
  writePrivate(path, `${JSON.stringify(cfg, null, 1)}\n`);
271
303
  }
304
+ /** Persist the final status unless `--cancel` already marked the relay. */
305
+ function writeFinalStatus(dir, status) {
306
+ try {
307
+ const cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
308
+ if (cfg.cancelled || cfg.status === "CANCELLED")
309
+ return;
310
+ updateConfig(dir, { status });
311
+ }
312
+ catch {
313
+ // the directory is gone; nothing to record
314
+ }
315
+ }
272
316
  /** Remove the token copy once the relay no longer needs it. */
273
317
  export function scrubRelayToken(dir) {
274
318
  const path = join(dir, "config.json");
@@ -293,10 +337,12 @@ export function launchRelay(dir, env = process.env) {
293
337
  "submit", "-l", label, "-o", log, "-e", log, "--",
294
338
  "/usr/bin/env", `PATH=${env.PATH ?? "/usr/bin:/bin"}`, `HOME=${env.HOME ?? ""}`,
295
339
  ...(env.ZSWARM_STATE_DIR ? [`ZSWARM_STATE_DIR=${env.ZSWARM_STATE_DIR}`] : []),
340
+ ...(env.ZSWARM_RELAY_DELAYS ? [`ZSWARM_RELAY_DELAYS=${env.ZSWARM_RELAY_DELAYS}`] : []),
296
341
  `ZSWARM_RELAY_LABEL=${label}`, ...argv,
297
342
  ], { encoding: "utf8" });
298
343
  if (res.status !== 0)
299
344
  throw new ZellijError("failed", `launchctl submit failed: ${(res.stderr ?? "").trim().slice(0, 200)}`);
345
+ updateConfig(dir, { job: { kind: "launchd", label } });
300
346
  return `launchd job ${label}`;
301
347
  }
302
348
  if (process.platform === "linux" && spawnSync("systemctl", ["--user", "is-system-running"], { encoding: "utf8" }).status !== null) {
@@ -304,13 +350,18 @@ export function launchRelay(dir, env = process.env) {
304
350
  const res = spawnSync("systemd-run", ["--user", "--collect", "--quiet", `--unit=${unit}`,
305
351
  `--setenv=PATH=${env.PATH ?? "/usr/bin:/bin"}`,
306
352
  ...(env.ZSWARM_STATE_DIR ? [`--setenv=ZSWARM_STATE_DIR=${env.ZSWARM_STATE_DIR}`] : []),
353
+ ...(env.ZSWARM_RELAY_DELAYS ? [`--setenv=ZSWARM_RELAY_DELAYS=${env.ZSWARM_RELAY_DELAYS}`] : []),
307
354
  ...argv], { encoding: "utf8" });
308
- if (res.status === 0)
355
+ if (res.status === 0) {
356
+ updateConfig(dir, { job: { kind: "systemd", unit } });
309
357
  return `systemd unit ${unit}`;
358
+ }
310
359
  }
311
360
  const out = openLog(log);
312
361
  const child = spawn(argv[0], argv.slice(1), { detached: true, stdio: ["ignore", out, out], env, windowsHide: true });
313
362
  child.unref();
363
+ if (child.pid)
364
+ updateConfig(dir, { job: { kind: "pid", pid: child.pid } });
314
365
  return `pid ${child.pid}`;
315
366
  }
316
367
  /** A file descriptor the detached child can inherit. */
@@ -338,6 +389,7 @@ export async function startRelay(input) {
338
389
  replySession: input.replySession?.trim() || env.ZSWARM_SESSION?.trim() || env.ZELLIJ_SESSION_NAME?.trim() || undefined,
339
390
  from: input.from?.trim() || "zswarm-relay",
340
391
  timeoutMin: input.timeoutMin ?? 240,
392
+ maxAgeMin: input.maxAgeMin ?? (input.timeoutMin ?? 240) + DEFAULT_MAX_AGE_EXTRA_MIN,
341
393
  };
342
394
  // Read the body before the relay directory (and its token copy) exists.
343
395
  const body = input.bodyFile ? readFileSync(input.bodyFile, "utf8") : undefined;
@@ -452,6 +504,193 @@ export async function waitForRelay(id, opts = {}) {
452
504
  await sleep(opts.pollMs ?? 15_000);
453
505
  }
454
506
  }
507
+ /** A pid's command line: `ps` on POSIX, CIM on Windows. */
508
+ function defaultCommandLine(pid) {
509
+ const res = process.platform === "win32"
510
+ ? spawnSync("powershell.exe", ["-NoProfile", "-Command",
511
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf8", windowsHide: true })
512
+ : spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
513
+ const text = res.status === 0 ? (res.stdout ?? "").trim() : "";
514
+ return text || undefined;
515
+ }
516
+ const defaultJobRunner = (cmd, args) => {
517
+ const res = spawnSync(cmd, args, { encoding: "utf8" });
518
+ return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
519
+ };
520
+ /** The run directories under relaysDir, with the start time from each name. */
521
+ function runDirs(root) {
522
+ if (!existsSync(root))
523
+ return [];
524
+ const out = [];
525
+ for (const name of readdirSync(root)) {
526
+ if (name === "undelivered")
527
+ continue;
528
+ const path = join(root, name);
529
+ let isDir = false;
530
+ try {
531
+ isDir = statSync(path).isDirectory();
532
+ }
533
+ catch {
534
+ continue;
535
+ }
536
+ if (!isDir)
537
+ continue;
538
+ const m = /^(.*)-(\d+)$/.exec(name);
539
+ out.push({
540
+ id: m ? m[1] : name,
541
+ dir: path,
542
+ started: (m ? Number(m[2]) : Math.floor(statSync(path).mtimeMs / 1000)) * 1000,
543
+ });
544
+ }
545
+ return out;
546
+ }
547
+ function readRelayConfig(dir) {
548
+ try {
549
+ return JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
550
+ }
551
+ catch {
552
+ return undefined;
553
+ }
554
+ }
555
+ /** The state a relay dir already records: a cancel marker, a final status, a message, else running. */
556
+ function relayState(dir, cfg) {
557
+ if (cfg?.cancelled || cfg?.status === "CANCELLED")
558
+ return "cancelled";
559
+ if (cfg?.status)
560
+ return cfg.status.toLowerCase();
561
+ const message = join(dir, "message.txt");
562
+ if (existsSync(message)) {
563
+ const m = /^\s*RELAY \S+ STATUS (\S+)/m.exec(readFileSync(message, "utf8"));
564
+ return (m?.[1] ?? "done").toLowerCase();
565
+ }
566
+ return "running";
567
+ }
568
+ /** The last `check=<n>` a relay logged, if any. */
569
+ function lastCheckResult(dir) {
570
+ try {
571
+ const matches = [...readFileSync(join(dir, "relay.log"), "utf8").matchAll(/check=(-?\d+)/g)];
572
+ const last = matches.at(-1)?.[1];
573
+ return last === undefined ? undefined : Number(last);
574
+ }
575
+ catch {
576
+ return undefined;
577
+ }
578
+ }
579
+ function jobLabelText(job) {
580
+ if (job.kind === "launchd")
581
+ return `launchd job ${job.label}`;
582
+ if (job.kind === "systemd")
583
+ return `systemd unit ${job.unit}`;
584
+ return `pid ${job.pid}`;
585
+ }
586
+ /**
587
+ * True while the recorded job is still loaded (launchd/systemd), or while the
588
+ * pid is alive and its command line still names this relay's directory (a
589
+ * relay that died without a final status leaves a pid another process may reuse).
590
+ */
591
+ function jobLoaded(job, deps, dir) {
592
+ const run = deps.run ?? defaultJobRunner;
593
+ if (job.kind === "launchd" && job.label)
594
+ return run("launchctl", ["list", job.label]).status === 0;
595
+ if (job.kind === "systemd" && job.unit) {
596
+ const res = run("systemctl", ["--user", "is-active", job.unit]);
597
+ return res.status === 0 && /^(active|activating|reloading)$/.test(res.stdout.trim());
598
+ }
599
+ if (job.kind === "pid" && job.pid) {
600
+ const alive = deps.alive ?? ((pid) => (process.kill(pid, 0), true));
601
+ try {
602
+ if (!alive(job.pid))
603
+ return false;
604
+ }
605
+ catch {
606
+ return false;
607
+ }
608
+ return (deps.commandLine ?? defaultCommandLine)(job.pid)?.includes(dir) ?? false;
609
+ }
610
+ return false;
611
+ }
612
+ /** One row per relay run directory; newest first. */
613
+ export function listRelays(deps = {}) {
614
+ const env = deps.env ?? process.env;
615
+ const now = deps.now ?? Date.now;
616
+ const rows = [];
617
+ for (const run of runDirs(relaysDir(env))) {
618
+ const cfg = readRelayConfig(run.dir);
619
+ if (!cfg)
620
+ continue;
621
+ const loaded = cfg.job ? jobLoaded(cfg.job, deps, run.dir) : false;
622
+ rows.push({
623
+ id: cfg.id || run.id,
624
+ dir: run.dir,
625
+ to: cfg.to,
626
+ session: cfg.session,
627
+ started: run.started,
628
+ age: Math.max(0, Math.floor((now() - run.started) / 1000)),
629
+ state: relayState(run.dir, cfg),
630
+ lastCheck: lastCheckResult(run.dir),
631
+ ...(loaded && cfg.job ? { job: jobLabelText(cfg.job) } : {}),
632
+ });
633
+ }
634
+ return rows.sort((a, b) => b.started - a.started);
635
+ }
636
+ function stopJob(job, deps) {
637
+ const run = deps.run ?? defaultJobRunner;
638
+ if (job.kind === "launchd" && job.label) {
639
+ const res = run("launchctl", ["remove", job.label]);
640
+ if (res.status !== 0)
641
+ throw new ZellijError("failed", `launchctl remove ${job.label} failed: ${(res.stderr || res.stdout).trim().slice(0, 200)}`);
642
+ }
643
+ else if (job.kind === "systemd" && job.unit) {
644
+ const res = run("systemctl", ["--user", "stop", job.unit]);
645
+ if (res.status !== 0)
646
+ throw new ZellijError("failed", `systemctl --user stop ${job.unit} failed: ${(res.stderr || res.stdout).trim().slice(0, 200)}`);
647
+ }
648
+ else if (job.kind === "pid" && job.pid) {
649
+ const kill = deps.kill ?? ((pid) => { process.kill(pid, "SIGTERM"); });
650
+ kill(job.pid);
651
+ }
652
+ else {
653
+ return "no job recorded; nothing to stop";
654
+ }
655
+ return `stopped ${jobLabelText(job)}`;
656
+ }
657
+ /** Stop a running relay's job, mark its directory CANCELLED and scrub its token. */
658
+ export function cancelRelay(id, deps = {}) {
659
+ const env = deps.env ?? process.env;
660
+ const now = deps.now ?? Date.now;
661
+ const match = runDirs(relaysDir(env))
662
+ .filter((r) => r.id === id)
663
+ .sort((a, b) => b.started - a.started)[0];
664
+ if (!match)
665
+ throw new ZellijError("usage", `no relay ${id}`);
666
+ const cfg = readRelayConfig(match.dir);
667
+ if (!cfg)
668
+ throw new ZellijError("failed", `relay ${id} has no config.json`);
669
+ const state = relayState(match.dir, cfg);
670
+ if (state !== "running") {
671
+ return { id, cancelled: false, state, reason: `relay ${id} is already ${state}; nothing to cancel`, actions: [] };
672
+ }
673
+ // Stop only a job that is still this relay's: a crashed relay's label may be
674
+ // gone and its pid reused. Either way the directory is marked cancelled.
675
+ const actions = [
676
+ !cfg.job
677
+ ? "no job recorded; nothing to stop"
678
+ : jobLoaded(cfg.job, deps, match.dir)
679
+ ? stopJob(cfg.job, deps)
680
+ : `${jobLabelText(cfg.job)} is no longer running this relay; nothing to stop`,
681
+ ];
682
+ updateConfig(match.dir, { cancelled: true, status: "CANCELLED", cancelledAt: now() });
683
+ scrubRelayToken(match.dir);
684
+ actions.push(`${id} marked CANCELLED (nothing pasted)`);
685
+ return { id, cancelled: true, state: "cancelled", reason: `cancelled relay ${id}`, actions };
686
+ }
687
+ /** Cancel every running relay that targets `to`. */
688
+ export function cancelAllTo(to, deps = {}) {
689
+ const env = deps.env ?? process.env;
690
+ return listRelays({ ...deps, env })
691
+ .filter((row) => row.to === to && row.state === "running")
692
+ .map((row) => cancelRelay(row.id, { ...deps, env }));
693
+ }
455
694
  /** Remove relay directories and undelivered messages older than `days`. */
456
695
  export function pruneRelays(days = 14, env = process.env) {
457
696
  const root = relaysDir(env);
@@ -48,8 +48,13 @@ export type Runner = (cmd: string, args: string[]) => RunResult;
48
48
  export type ServiceState = {
49
49
  name: string;
50
50
  unit: string;
51
+ /** Human summary: `running (pid 42)`, `timer every 120 s, loaded`, `not loaded`. */
51
52
  state: string;
52
53
  active: boolean;
54
+ /** Main pid when the platform reports one (daemons). */
55
+ pid?: number;
56
+ /** Last exit status when the platform reports one. */
57
+ lastExit?: number;
53
58
  };
54
59
  export type ServiceHost = {
55
60
  account: ServiceAccount;
@@ -61,6 +66,8 @@ export type ServiceHost = {
61
66
  exists(path: string): boolean;
62
67
  mkdir(path: string): void;
63
68
  };
69
+ /** Synchronous sleep; injected in tests so the launchd wait is instant. */
70
+ sleep?: (ms: number) => void;
64
71
  };
65
72
  export declare const defaultRunner: Runner;
66
73
  /** The account services are installed for: the current user, or `runAs` when root. */
@@ -87,7 +94,7 @@ export declare function ensureLinger(host: ServiceHost): {
87
94
  note?: string;
88
95
  };
89
96
  export declare function launchdLabel(account: ServiceAccount, name: string): string;
90
- export declare function launchdPlist(account: ServiceAccount, spec: ServiceSpec): string;
97
+ export declare function launchdPlist(account: ServiceAccount, spec: ServiceSpec, run?: Runner): string;
91
98
  /** Write, (re)load and start the services. Returns what it did, one line per unit. */
92
99
  export declare function installServices(host: ServiceHost, specs: ServiceSpec[]): {
93
100
  lines: string[];
@@ -95,7 +102,7 @@ export declare function installServices(host: ServiceHost, specs: ServiceSpec[])
95
102
  };
96
103
  /** Stop and delete the services; missing ones are fine. */
97
104
  export declare function removeServices(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): string[];
98
- export declare function serviceStates(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): ServiceState[];
105
+ export declare function serviceStates(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind" | "intervalSec">>): ServiceState[];
99
106
  /** Create dir and any missing parents, owned by the account when installing for it. */
100
107
  export declare function ensureAccountDir(account: ServiceAccount, dir: string, mode?: number): void;
101
108
  /** Write a mode-600 file for the account, creating its directory (700) as needed. */
@@ -11,6 +11,14 @@ export const defaultRunner = (cmd, args) => {
11
11
  const res = spawnSync(cmd, args, { encoding: "utf8" });
12
12
  return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
13
13
  };
14
+ /** A synchronous sleep for install paths (Node allows Atomics.wait on its main thread). */
15
+ function defaultSleep(ms) {
16
+ if (ms > 0)
17
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
18
+ }
19
+ function hostSleep(host, ms) {
20
+ (host.sleep ?? defaultSleep)(ms);
21
+ }
14
22
  /** The account services are installed for: the current user, or `runAs` when root. */
15
23
  export function serviceAccount(runAs, run = defaultRunner) {
16
24
  const platform = process.platform;
@@ -181,11 +189,30 @@ function launchdPlistPath(account, label) {
181
189
  function xml(text) {
182
190
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
183
191
  }
184
- export function launchdPlist(account, spec) {
192
+ /**
193
+ * The account's per-user temp dir. launchd gives daemons no TMPDIR, so Zellij
194
+ * would fall back to it anyway; naming it explicitly lets every caller (an SSH
195
+ * shell, a script that sets TMPDIR) agree on one Zellij socket dir. Resolved as
196
+ * the account so root writing a foreign LaunchDaemon gets that account's dir.
197
+ */
198
+ function darwinUserTempDir(account, run) {
199
+ const argv = [...(account.foreign ? accountArgv(account) : []), "getconf", "DARWIN_USER_TEMP_DIR"];
200
+ const [cmd, ...args] = argv;
201
+ if (!cmd)
202
+ return undefined;
203
+ const res = run(cmd, args);
204
+ const dir = res.status === 0 ? res.stdout.trim() : "";
205
+ return dir || undefined;
206
+ }
207
+ export function launchdPlist(account, spec, run = defaultRunner) {
185
208
  checkName(spec.name);
186
209
  const label = launchdLabel(account, spec.name);
187
210
  const log = join(account.home, "Library", "Logs", "zswarm", `${spec.name}.log`);
188
- const env = { HOME: account.home, USER: account.user, ...(spec.env ?? {}) };
211
+ const env = { HOME: account.home, USER: account.user };
212
+ const tmpdir = darwinUserTempDir(account, run);
213
+ if (tmpdir)
214
+ env.TMPDIR = tmpdir;
215
+ Object.assign(env, spec.env ?? {});
189
216
  const lines = [
190
217
  '<?xml version="1.0" encoding="UTF-8"?>',
191
218
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
@@ -217,6 +244,36 @@ function launchdDomain(host) {
217
244
  const gui = `gui/${a.uid}`;
218
245
  return host.run("launchctl", ["print", gui]).status === 0 ? gui : `user/${a.uid}`;
219
246
  }
247
+ /** launchd removes a booted-out job asynchronously; wait until print stops seeing it. */
248
+ const BOOTOUT_WAIT_MS = 10_000;
249
+ const BOOTOUT_POLL_MS = 250;
250
+ /** bootstrap racing that teardown fails with EIO (5) or 37; retry a couple of times. */
251
+ const BOOTSTRAP_RETRIES = 2;
252
+ const BOOTSTRAP_RETRY_MS = 1_000;
253
+ function waitForBootout(host, domain, label) {
254
+ const deadline = Date.now() + BOOTOUT_WAIT_MS;
255
+ for (;;) {
256
+ if (host.run("launchctl", ["print", `${domain}/${label}`]).status !== 0)
257
+ return;
258
+ const left = deadline - Date.now();
259
+ if (left <= 0)
260
+ return;
261
+ hostSleep(host, Math.min(BOOTOUT_POLL_MS, left));
262
+ }
263
+ }
264
+ function bootstrapLaunchd(host, domain, path, label) {
265
+ let last;
266
+ for (let attempt = 0; attempt <= BOOTSTRAP_RETRIES; attempt++) {
267
+ const res = host.run("launchctl", ["bootstrap", domain, path]);
268
+ if (res.status === 0)
269
+ return;
270
+ last = res;
271
+ if (!/Bootstrap failed: (5|37):/.test(res.stderr || res.stdout) || attempt === BOOTSTRAP_RETRIES)
272
+ break;
273
+ hostSleep(host, BOOTSTRAP_RETRY_MS);
274
+ }
275
+ must(last, `launchctl bootstrap ${domain} ${label}`);
276
+ }
220
277
  // ------------------------------------------------------------------ common
221
278
  /** Write, (re)load and start the services. Returns what it did, one line per unit. */
222
279
  export function installServices(host, specs) {
@@ -257,9 +314,10 @@ export function installServices(host, specs) {
257
314
  for (const spec of specs) {
258
315
  const label = launchdLabel(a, spec.name);
259
316
  const path = launchdPlistPath(a, label);
260
- fs.write(path, launchdPlist(a, spec), 0o644);
317
+ fs.write(path, launchdPlist(a, spec, host.run), 0o644);
261
318
  host.run("launchctl", ["bootout", `${domain}/${label}`]);
262
- must(host.run("launchctl", ["bootstrap", domain, path]), `launchctl bootstrap ${domain} ${label}`);
319
+ waitForBootout(host, domain, label);
320
+ bootstrapLaunchd(host, domain, path, label);
263
321
  lines.push(`${label} loaded in ${domain}`);
264
322
  }
265
323
  return { lines, notes };
@@ -298,7 +356,16 @@ export function serviceStates(host, specs) {
298
356
  return specs.map((spec) => {
299
357
  const unit = systemdUnitName(spec.name, spec.kind);
300
358
  const state = systemctl(host, ["is-active", unit]).stdout.trim() || "unknown";
301
- return { name: spec.name, unit, state, active: state === "active" };
359
+ const props = systemctl(host, ["show", unit, "-p", "MainPID", "-p", "ExecMainStatus"]).stdout;
360
+ const pid = Number(/^MainPID=(\d+)$/m.exec(props)?.[1] ?? NaN);
361
+ const exit = /^ExecMainStatus=(-?\d+)$/m.exec(props)?.[1];
362
+ const lastExit = exit === undefined ? undefined : Number(exit);
363
+ return {
364
+ name: spec.name,
365
+ unit,
366
+ active: state === "active",
367
+ ...describeJob(spec, state, Number.isFinite(pid) && pid > 0 ? pid : undefined, lastExit),
368
+ };
302
369
  });
303
370
  }
304
371
  const domain = launchdDomain(host);
@@ -307,12 +374,41 @@ export function serviceStates(host, specs) {
307
374
  const res = host.run("launchctl", ["print", `${domain}/${label}`]);
308
375
  if (res.status !== 0)
309
376
  return { name: spec.name, unit: label, state: "not loaded", active: false };
310
- const state = /^\s*state = (.+)$/m.exec(res.stdout)?.[1]?.trim() ?? "loaded";
311
- // Oneshot and interval jobs sit "not running" between runs; loaded is up for them.
312
- const active = spec.kind === "daemon" ? state === "running" : true;
313
- return { name: spec.name, unit: label, state, active };
377
+ const raw = /^\s*state = (.+)$/m.exec(res.stdout)?.[1]?.trim() ?? "loaded";
378
+ const pid = Number(/^\s*pid = (\d+)$/m.exec(res.stdout)?.[1] ?? NaN);
379
+ const exit = /last exit code = (-?\d+)/.exec(res.stdout)?.[1];
380
+ const lastExit = exit === undefined ? undefined : Number(exit);
381
+ const pidOrUndef = Number.isFinite(pid) && pid > 0 ? pid : undefined;
382
+ // Oneshot and interval jobs sit "not running" between runs; being loaded is up for them.
383
+ const active = spec.kind === "daemon" ? raw === "running" : true;
384
+ return {
385
+ name: spec.name,
386
+ unit: label,
387
+ active,
388
+ ...describeJob(spec, spec.kind === "daemon" ? raw : "loaded", pidOrUndef, lastExit),
389
+ };
314
390
  });
315
391
  }
392
+ /**
393
+ * Word a service by what it is: a daemon is running with a pid, an interval
394
+ * job is a timer and a oneshot has run; the raw platform state reads like a
395
+ * failure between timer runs.
396
+ */
397
+ function describeJob(spec, healthy, pid, lastExit) {
398
+ const parts = [];
399
+ if (spec.kind === "daemon") {
400
+ parts.push(pid === undefined ? healthy : `running (pid ${pid})`);
401
+ }
402
+ else if (spec.kind === "interval") {
403
+ parts.push(`timer every ${Math.max(30, Math.floor(spec.intervalSec ?? 120))} s`, healthy);
404
+ }
405
+ else {
406
+ parts.push("oneshot", healthy);
407
+ }
408
+ if (lastExit !== undefined)
409
+ parts.push(`last exit ${lastExit}`);
410
+ return { state: parts.join(", "), pid, lastExit };
411
+ }
316
412
  function must(res, what) {
317
413
  if (res.status !== 0) {
318
414
  throw new ZellijError("failed", `${what} failed: ${(res.stderr || res.stdout).trim().slice(0, 300)}`);
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecRes
4
4
  export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
5
5
  export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
6
6
  export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
7
- export { liveSessionNames, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
7
+ export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, type ZellijSession, type ZellijSessionResolve, } from "./zellij/session.js";
8
8
  export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, type WaitRequest, } from "./zellij/args.js";
9
9
  export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
10
10
  export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
@@ -34,5 +34,5 @@ export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
34
34
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, type ServeServiceInput, } from "./host/serve-service.js";
35
35
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, type Runner, type ServiceAccount, type ServiceHost, type ServiceKind, type ServiceSpec, type ServiceState, } from "./host/services.js";
36
36
  export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, type CrewDeps, type CrewUpInput, } from "./host/crew.js";
37
- export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayConfig, type RelayDeps, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
37
+ export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, type RelayAdminDeps, type RelayCancelResult, type RelayConfig, type RelayDeps, type RelayJob, type RelayJobRunner, type RelayRow, type RelayStartInput, type RelayStatus, } from "./host/relay.js";
38
38
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, type HostCheck, type HostDoctorInput, type HostInstallInput, type SshRunner, } from "./host/host.js";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
4
4
  export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
5
5
  export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, } from "./zellij/binary.js";
6
6
  export { normalizePaneId, parsePaneList, resolvePane, } from "./zellij/panes.js";
7
- export { liveSessionNames, parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
7
+ export { liveSessionNames, parseCreatedAge, parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
8
8
  export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, } from "./zellij/args.js";
9
9
  export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
10
10
  export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, } from "./ops/bus.js";
@@ -32,5 +32,5 @@ export { findPython, runSlot, slotScriptPath } from "./host/slot.js";
32
32
  export { installServeService, serveServiceName, serveServiceSpec, serveTokenFromEnv, serveTokenPath, uninstallServeService, SERVE_AWAIT_SESSION_ENV, SERVE_TOKEN_FILE_ENV, } from "./host/serve-service.js";
33
33
  export { accountArgv, installServices, launchdLabel, launchdPlist, removeServices, serviceAccount, serviceStates, systemdUnitName, systemdUnits, writeAccountFile, } from "./host/services.js";
34
34
  export { crewCheck, crewDir, crewDown, crewServiceSpecs, crewStart, crewStatus, crewUp, firstLayoutPane, parseEnvFile, CREW_UNIT_ENV, CREW_WATCH_SEC, } from "./host/crew.js";
35
- export { deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
35
+ export { cancelAllTo, cancelRelay, deliverMessage, doneLines, fillMessage, freshDoneLine, launchRelay, listRelays, prepareRelay, pruneRelays, relayLoop, relaysDir, runRelayDir, scrubRelayToken, startRelay, waitForRelay, } from "./host/relay.js";
36
36
  export { hostChecks, hostDoctor, hostInstall, hostInstallArgs, hostScriptPath, parseHostReport, } from "./host/host.js";
@@ -2,7 +2,7 @@ import type { StateStore } from "../state.js";
2
2
  import type { ZellijClient } from "../zellij/client.js";
3
3
  import type { ZellijPane } from "../zellij/panes.js";
4
4
  import type { Clock } from "./types.js";
5
- export type Submitted = boolean | "unverified";
5
+ export type Submitted = boolean | "queued" | "unverified" | "not-delivered";
6
6
  export type DeliveryResult = {
7
7
  to: string;
8
8
  ok: boolean;
@@ -14,6 +14,36 @@ export type DeliveryResult = {
14
14
  };
15
15
  };
16
16
  export type SubmitMode = "auto" | "double-enter" | "none";
17
+ /** Fixed send windows before adaptive scaling; explicit flags always win. */
18
+ export declare const SEND_BUDGETS: {
19
+ /** Pause before checking the paste landed. */
20
+ readonly settleMs: 300;
21
+ /** Pane-lookup retry window for an absent pane. */
22
+ readonly observeMs: 1000;
23
+ /** The scaled window never exceeds this multiple of the default. */
24
+ readonly maxScale: 5;
25
+ /** A first Zellij call at or under this many ms is not "slow". */
26
+ readonly slowMs: 1000;
27
+ readonly settleMaxMs: 5000;
28
+ readonly observeMaxMs: 30000;
29
+ };
30
+ /**
31
+ * Scale the fixed settle/observe windows by how slow the send's first Zellij
32
+ * call was. A call under `slowMs` keeps the defaults; five seconds or more pins
33
+ * the 5x cap. Explicit `--settle-ms` / `--observe-ms` are returned as given,
34
+ * and a scaled value is capped by the call's own timeout so it cannot outlast
35
+ * the observation budget.
36
+ */
37
+ export declare function scaleBudgets(input: {
38
+ firstCallMs: number;
39
+ timeoutMs: number;
40
+ settleMs?: number;
41
+ observeMs?: number;
42
+ }): {
43
+ settleMs: number;
44
+ observeMs: number;
45
+ scale: number;
46
+ };
17
47
  export type SenderSource = "arg" | "env" | "title" | "default";
18
48
  export type SenderLabel = {
19
49
  label: string;
@@ -54,14 +84,29 @@ export declare function resolveSubmitMode(args: Record<string, unknown>, pane: {
54
84
  command?: string | null;
55
85
  title?: string | null;
56
86
  }): SubmitMode;
87
+ /**
88
+ * The body text to look for on screen: its first non-empty line, trimmed to 40
89
+ * chars. A prefix of the whole body (`body.slice(0, 40)`) can span a newline and
90
+ * then never matches any single screen line. A leading `[zswarm from=…]` header
91
+ * is skipped so we look for the caller's own first line.
92
+ */
93
+ export declare function bodyNeedle(body: string): string;
57
94
  /** True when the paste still sits in the composer (bottom of the screen). */
58
95
  export declare function composerHolds(screen: string, body: string): boolean;
59
96
  /**
60
97
  * Compare the pane before vs after the paste.
61
98
  * Composer clearing (pasted text left the bottom input region) is submission.
62
99
  * `[Pasted text` in scrollback is not evidence.
100
+ *
101
+ * `not-delivered` is deliberately conservative: only when the screen is
102
+ * unchanged once the paste/composer itself is discounted and nothing is queued.
103
+ * A screen that changed while the body is gone is `unverified` — the pane may
104
+ * have taken the message and scrolled it away, and a false `not-delivered`
105
+ * makes the caller resend the whole message.
63
106
  */
64
- export declare function classifySubmit(before: string, after: string, body: string): Submitted;
107
+ export declare function classifySubmit(before: string, after: string, body: string, opts?: {
108
+ queued?: (screen: string) => boolean;
109
+ }): Submitted;
65
110
  /** Paste a body into one pane and record the attempt. */
66
111
  export declare function deliverTo(client: ZellijClient, state: StateStore, args: Record<string, unknown>, input: {
67
112
  session: string;
@@ -70,4 +115,8 @@ export declare function deliverTo(client: ZellijClient, state: StateStore, args:
70
115
  op: string;
71
116
  at: number;
72
117
  clock: Clock;
118
+ /** How long the send's first Zellij call took, for adaptive budgets. */
119
+ firstCallMs?: number;
120
+ /** The observation timeout the scaled windows are capped by. */
121
+ timeoutMs?: number;
73
122
  }): Promise<DeliveryResult>;