@zswarm/core 0.1.8 → 0.2.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.
@@ -40,12 +40,13 @@ export declare function statusTabs(peers: Record<string, unknown>[]): {
40
40
  panes: number;
41
41
  states: Record<string, number>;
42
42
  }[];
43
- export declare function classify(input: {
43
+ type ScreenPair = {
44
44
  exited: boolean;
45
45
  before: string;
46
46
  after: string;
47
47
  profile?: HarnessProfile | null;
48
- }): PeerState;
48
+ };
49
+ export declare function classify(input: ScreenPair): PeerState;
49
50
  /** Run `fn` over items with at most `limit` in flight. */
50
51
  export declare function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
51
52
  /**
@@ -59,3 +60,4 @@ export declare function peerStatus(client: ZellijClient, args: Record<string, un
59
60
  deadlineAt?: number;
60
61
  signal?: AbortSignal;
61
62
  }): Promise<OpsResult>;
63
+ export {};
@@ -53,9 +53,6 @@ export function waitingPrompt(screen, profile) {
53
53
  const last = lines[lines.length - 1] ?? "";
54
54
  return QUESTION.test(last) ? { reason: "prompt", evidence: last.slice(0, 320), source: "screen" } : null;
55
55
  }
56
- function promptHolds(screen, profile) {
57
- return waitingPrompt(screen, profile) !== null;
58
- }
59
56
  /** Compact summary of the selected terminal peers, including inactive tabs. */
60
57
  export function statusTabs(peers) {
61
58
  const tabs = new Map();
@@ -69,12 +66,17 @@ export function statusTabs(peers) {
69
66
  }
70
67
  return [...tabs.values()];
71
68
  }
72
- export function classify(input) {
69
+ /** The verdict and, for `waiting`, the prompt behind it — one screen scan. */
70
+ function classifyWithEvidence(input) {
73
71
  if (input.exited)
74
- return "exited";
72
+ return { state: "exited", waiting: null };
75
73
  if (input.before !== input.after)
76
- return "busy";
77
- return promptHolds(input.after, input.profile) ? "waiting" : "idle";
74
+ return { state: "busy", waiting: null };
75
+ const waiting = waitingPrompt(input.after, input.profile);
76
+ return { state: waiting ? "waiting" : "idle", waiting };
77
+ }
78
+ export function classify(input) {
79
+ return classifyWithEvidence(input).state;
78
80
  }
79
81
  /** Run `fn` over items with at most `limit` in flight. */
80
82
  export async function mapPool(items, limit, fn) {
@@ -96,6 +98,7 @@ function isCancelledError(err) {
96
98
  return ((err instanceof ZellijError && err.code === "cancelled") ||
97
99
  (err instanceof Error && /cancelled/i.test(err.message)));
98
100
  }
101
+ /** A `waiting` peer carries its evidence in `extra`, found when it was classified. */
99
102
  function peerEntry(pane, state, screen, verbose, extra) {
100
103
  const entry = {
101
104
  id: pane.id,
@@ -106,8 +109,6 @@ function peerEntry(pane, state, screen, verbose, extra) {
106
109
  lastLine: lastLine(screen).slice(0, 160),
107
110
  ...extra,
108
111
  };
109
- if (state === "waiting")
110
- entry.waiting = waitingPrompt(screen, resolveHarness(pane));
111
112
  if (verbose) {
112
113
  entry.command = pane.command ?? null;
113
114
  entry.cwd = pane.cwd ?? null;
@@ -185,11 +186,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
185
186
  return peerEntry(pane, "exited", "", verbose);
186
187
  }
187
188
  const row = changed.get(pane.id);
189
+ const waiting = row ? waitingPrompt(row.screen, resolveHarness(pane)) : null;
188
190
  const state = !row ? "unknown"
189
- : promptHolds(row.screen, resolveHarness(pane)) ? "waiting"
191
+ : waiting ? "waiting"
190
192
  : row.first ? "unknown" : row.changed ? "busy" : "idle";
191
193
  return peerEntry(pane, state, row?.screen ?? "", verbose, {
192
194
  ...(row?.first ? { first: true } : {}),
195
+ ...(waiting ? { waiting } : {}),
193
196
  });
194
197
  })
195
198
  .sort((a, b) => String(a.id).localeCompare(String(b.id)));
@@ -315,13 +318,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
315
318
  peers.push(peerEntry(pane, "unknown", after ?? first ?? "", verbose));
316
319
  continue;
317
320
  }
318
- const state = classify({
321
+ const { state, waiting } = classifyWithEvidence({
319
322
  exited: false,
320
323
  before: first,
321
324
  after,
322
325
  profile: resolveHarness(pane),
323
326
  });
324
- peers.push(peerEntry(pane, state, after, verbose));
327
+ peers.push(peerEntry(pane, state, after, verbose, waiting ? { waiting } : undefined));
325
328
  }
326
329
  peers.sort((a, b) => String(a.id).localeCompare(String(b.id)));
327
330
  const free = peers.filter((p) => p.state === "idle").map((p) => p.id);
@@ -15,6 +15,7 @@ export type OpsResult = ({
15
15
  };
16
16
  import type { GitClient } from "../git.js";
17
17
  import type { Policy } from "../policy.js";
18
+ import type { ServeServiceInput } from "../host/serve-service.js";
18
19
  import type { StateStore } from "../state.js";
19
20
  /** Injectable clock, git, state, and policy so timing/IO ops stay testable. */
20
21
  export type DispatchDeps = {
@@ -46,6 +47,8 @@ export type DispatchDeps = {
46
47
  networkInterfaces?: () => NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>;
47
48
  /** Narrow injectables for Windows `serve --install` / `--clear`. */
48
49
  serveInstall?: ServeInstallDeps;
50
+ /** Linux/macOS serve --install/--clear seams (tests). */
51
+ serveService?: Partial<ServeServiceInput>;
49
52
  };
50
53
  export type ServePowerShellResult = {
51
54
  code: number;
package/dist/ops/wait.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ZellijError } from "../errors.js";
2
+ import { DEFAULT_TIMEOUT_MS } from "../zellij/binary.js";
2
3
  import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, truncateDumpText, } from "./util.js";
3
4
  const WAIT_DEFAULTS = {
4
5
  idleMs: 2_000,
@@ -10,6 +11,11 @@ const WAIT_LIMITS = {
10
11
  pollMs: { min: 50, max: 30_000 },
11
12
  timeoutMs: { min: 1_000, max: 900_000 },
12
13
  };
14
+ /**
15
+ * A poll at the deadline still gets this long, so `timeout` reports the screen
16
+ * at the deadline; a hung dump there overruns the wait by at most this much.
17
+ */
18
+ const FINAL_DUMP_MS = 1_000;
13
19
  export function buildMatcher(args) {
14
20
  const match = optionalString(args.match);
15
21
  if (!match)
@@ -98,15 +104,29 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
98
104
  let lastChangeAt = started;
99
105
  let polls = 0;
100
106
  let changes = 0;
107
+ let last = null;
101
108
  for (;;) {
102
109
  throwIfAborted(signal);
103
- const dumped = await client.dumpPane({
104
- session,
105
- paneId: pane.id,
106
- full: isTrue(args.full),
107
- });
110
+ const left = timeoutMs - (clock.now() - started);
111
+ let text;
112
+ try {
113
+ const dumped = await client.dumpPane({
114
+ session,
115
+ paneId: pane.id,
116
+ full,
117
+ timeoutMs: Math.max(FINAL_DUMP_MS, Math.min(left, DEFAULT_TIMEOUT_MS)),
118
+ });
119
+ text = dumped.text;
120
+ }
121
+ catch (err) {
122
+ throwIfAborted(signal);
123
+ // Out of time with a screen in hand: the wait timed out, it did not fail.
124
+ if (last && clock.now() - started >= timeoutMs) {
125
+ return waitResult("timeout", { ...last, at: clock.now() });
126
+ }
127
+ throw err;
128
+ }
108
129
  polls++;
109
- const text = dumped.text;
110
130
  const screen = normalizeScreen(text);
111
131
  const at = clock.now();
112
132
  const ctx = {
@@ -137,6 +157,8 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
137
157
  }
138
158
  if (at - started >= timeoutMs)
139
159
  return waitResult("timeout", ctx);
140
- await clock.sleep(pollMs);
160
+ last = ctx;
161
+ // The last poll lands on the deadline, not up to a whole pollMs past it.
162
+ await clock.sleep(Math.min(pollMs, timeoutMs - (at - started)));
141
163
  }
142
164
  }
package/dist/schema.js CHANGED
@@ -52,7 +52,7 @@ export const PARAMS = [
52
52
  name: "session",
53
53
  type: "string",
54
54
  flags: ["--session", "-s"],
55
- description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME). serve --install: readiness target only — does not change the task's default routing",
55
+ description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME). serve --install: on Windows the readiness target only (does not change the task's default routing); on Linux/macOS the session the service serves and waits for, one service per session",
56
56
  },
57
57
  {
58
58
  name: "to",
@@ -112,13 +112,19 @@ export const PARAMS = [
112
112
  name: "clear",
113
113
  type: "boolean",
114
114
  flags: ["--clear"],
115
- description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: stop and unregister the owned Windows zswarm-serve logon task if present",
115
+ description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: stop and unregister the owned Windows zswarm-serve logon task, or the Linux/macOS serve service, if present",
116
116
  },
117
117
  {
118
118
  name: "install",
119
119
  type: "boolean",
120
120
  flags: ["--install"],
121
- description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register the current-user Windows Interactive logon task and wait for authenticated hello plus host session visibility",
121
+ description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register the current-user Windows Interactive logon task, or on Linux/macOS a systemd user unit (with linger) or launchd job whose token lives in ~/.zswarm/serve/, and wait for authenticated hello plus host session visibility",
122
+ },
123
+ {
124
+ name: "runAs",
125
+ type: "string",
126
+ flags: ["--run-as"],
127
+ description: "serve --install/--clear on Linux/macOS, run as root: install the service for this user (a LaunchDaemon with UserName on macOS, the user's systemd units on Linux), for accounts that never log in",
122
128
  },
123
129
  {
124
130
  name: "reset",
@@ -496,7 +502,7 @@ export function cliUsage() {
496
502
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
497
503
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
498
504
  }
499
- lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
505
+ lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Hosts (CLI only, see docs/hosts.md): `zswarm slot`, `zswarm crew`, `zswarm relay`, `zswarm host`; `zswarm <command> --help` for each.", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
500
506
  return lines.join("\n");
501
507
  }
502
508
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
package/dist/state.d.ts CHANGED
@@ -29,6 +29,13 @@ export type BusMarkerRecord = {
29
29
  configKey: string;
30
30
  installedAt: number;
31
31
  };
32
+ /** How much of the end of the log `readLog` reads; older entries are not shown. */
33
+ export declare const LOG_TAIL_BYTES: number;
34
+ /**
35
+ * Past this, the append that crossed it trims the file back to its readable
36
+ * tail. Keeps the log bounded without needing a rotation daemon.
37
+ */
38
+ export declare const LOG_MAX_BYTES: number;
32
39
  export declare function defaultStateDir(env?: NodeJS.ProcessEnv): string;
33
40
  export type StateStoreOptions = {
34
41
  dir?: string;
package/dist/state.js CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  const LOG_FILE = "log.jsonl";
@@ -7,8 +7,13 @@ const SIGNALS_LOCK = "signals.lock";
7
7
  const CURSORS_FILE = "cursors.json";
8
8
  const CURSORS_LOCK = "cursors.lock";
9
9
  const BUS_FILE = "bus.json";
10
- /** Keeps the log bounded without needing a rotation daemon. */
11
- const LOG_TAIL_BYTES = 512 * 1024;
10
+ /** How much of the end of the log `readLog` reads; older entries are not shown. */
11
+ export const LOG_TAIL_BYTES = 512 * 1024;
12
+ /**
13
+ * Past this, the append that crossed it trims the file back to its readable
14
+ * tail. Keeps the log bounded without needing a rotation daemon.
15
+ */
16
+ export const LOG_MAX_BYTES = 4 * 1024 * 1024;
12
17
  /** Bound for waiting on a *live* lock holder before failing. */
13
18
  const LOCK_WAIT_MS = 5_000;
14
19
  /**
@@ -16,6 +21,45 @@ const LOCK_WAIT_MS = 5_000;
16
21
  * exclusive create (wait), not as abandoned debris (refuse).
17
22
  */
18
23
  const LOCK_PENDING_MS = LOCK_WAIT_MS;
24
+ /**
25
+ * The last `bytes` of a file, starting at its first whole line. Reads only
26
+ * that window, so a long-lived log does not cost a full read per `log` call.
27
+ */
28
+ function readFileTail(path, bytes) {
29
+ const fd = openSync(path, "r");
30
+ try {
31
+ const size = fstatSync(fd).size;
32
+ // One byte of lead-in says whether the window already starts a line.
33
+ const start = Math.max(0, size - bytes - 1);
34
+ const buf = Buffer.alloc(size - start);
35
+ let read = 0;
36
+ while (read < buf.length) {
37
+ const n = readSync(fd, buf, read, buf.length - read, start + read);
38
+ if (n === 0)
39
+ break;
40
+ read += n;
41
+ }
42
+ const text = buf.toString("utf8", 0, read);
43
+ return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
44
+ }
45
+ finally {
46
+ closeSync(fd);
47
+ }
48
+ }
49
+ /**
50
+ * Keep only a file's readable tail. Everything before it is already invisible
51
+ * to `readLog`; an append racing the rename can be lost, and the log is an aid.
52
+ */
53
+ function trimToTail(path, bytes) {
54
+ const tmp = `${path}.${process.pid}.tmp`;
55
+ try {
56
+ writeFileSync(tmp, readFileTail(path, bytes), "utf8");
57
+ renameSync(tmp, path);
58
+ }
59
+ catch {
60
+ rmSync(tmp, { force: true });
61
+ }
62
+ }
19
63
  function sleepSync(ms) {
20
64
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
21
65
  }
@@ -53,7 +97,10 @@ export function createStateStore(options = {}) {
53
97
  return;
54
98
  try {
55
99
  ensureDir();
56
- appendFileSync(join(dir, LOG_FILE), `${JSON.stringify(entry)}\n`, "utf8");
100
+ const path = join(dir, LOG_FILE);
101
+ appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
102
+ if (statSync(path).size > LOG_MAX_BYTES)
103
+ trimToTail(path, LOG_TAIL_BYTES);
57
104
  }
58
105
  catch {
59
106
  // The log is an aid, never a reason to fail an op.
@@ -62,15 +109,11 @@ export function createStateStore(options = {}) {
62
109
  function readLog() {
63
110
  let raw;
64
111
  try {
65
- raw = readFileSync(join(dir, LOG_FILE), "utf8");
112
+ raw = readFileTail(join(dir, LOG_FILE), LOG_TAIL_BYTES);
66
113
  }
67
114
  catch {
68
115
  return [];
69
116
  }
70
- if (raw.length > LOG_TAIL_BYTES) {
71
- raw = raw.slice(raw.length - LOG_TAIL_BYTES);
72
- raw = raw.slice(raw.indexOf("\n") + 1);
73
- }
74
117
  const out = [];
75
118
  for (const line of raw.split("\n")) {
76
119
  if (!line.trim())
@@ -148,6 +148,9 @@ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000,
148
148
  if (result.code === 0) {
149
149
  if (signal?.aborted)
150
150
  return false;
151
+ // A loaded host can return nothing; that is unresolved, not a wrong binary.
152
+ if (!text.trim())
153
+ return false;
151
154
  if (isZellijVersionOutput(result.stdout, result.stderr)) {
152
155
  identityCache.add(cacheKey);
153
156
  return true;
@@ -188,7 +191,7 @@ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_0
188
191
  capabilityCache.add(cacheKey);
189
192
  return true;
190
193
  }
191
- if (result.code === 0) {
194
+ if (result.code === 0 && text.trim()) {
192
195
  throw new ZellijError("zellij_incompatible", `Zellij at ${zellijPath} does not advertise list-sessions --no-formatting; upgrade Zellij (≥ 0.42) or zswarm`, zellijExecDetails(result));
193
196
  }
194
197
  // Soft: leave unresolved on transport failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {
@@ -19,6 +19,7 @@
19
19
  "license": "MIT",
20
20
  "files": [
21
21
  "dist",
22
+ "assets",
22
23
  "README.md"
23
24
  ],
24
25
  "publishConfig": {