@zswarm/core 0.1.3 → 0.1.5

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/dist/state.js CHANGED
@@ -1,14 +1,17 @@
1
- import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, 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";
5
5
  const SIGNALS_FILE = "signals.json";
6
6
  const SIGNALS_LOCK = "signals.lock";
7
7
  const CURSORS_FILE = "cursors.json";
8
+ const CURSORS_LOCK = "cursors.lock";
8
9
  const BUS_FILE = "bus.json";
9
10
  /** Keeps the log bounded without needing a rotation daemon. */
10
11
  const LOG_TAIL_BYTES = 512 * 1024;
11
12
  const LOCK_WAIT_MS = 5_000;
13
+ /** A live pid older than this is treated as a recycle of a crashed holder. */
14
+ const LOCK_STALE_MS = 30_000;
12
15
  function sleepSync(ms) {
13
16
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
17
  }
@@ -77,32 +80,112 @@ export function createStateStore(options = {}) {
77
80
  }
78
81
  return out;
79
82
  }
80
- function withSignalsLock(fn) {
83
+ function pidAlive(pid) {
84
+ try {
85
+ process.kill(pid, 0);
86
+ return true;
87
+ }
88
+ catch (err) {
89
+ // ESRCH: gone. EPERM: exists, just unsignalable — still a live holder.
90
+ return err.code === "EPERM";
91
+ }
92
+ }
93
+ /** Owner recorded in the lock file so a crash can be distinguished from a live holder. */
94
+ function readLockOwner(lockPath) {
95
+ try {
96
+ const rec = JSON.parse(readFileSync(lockPath, "utf8"));
97
+ if (typeof rec.pid !== "number" || !Number.isInteger(rec.pid) || rec.pid <= 0)
98
+ return null;
99
+ const at = typeof rec.at === "number" ? rec.at : 0;
100
+ return { pid: rec.pid, at };
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Dead pid → steal now. This process's own leftover (unlink failed, or a
108
+ * non-reentrant re-entry) → steal now. A live pid whose `at` is older than
109
+ * LOCK_STALE_MS is a recycled pid, not a holder still inside fn().
110
+ * Empty leftover from older writers (wx with no owner bytes) → steal once
111
+ * mtime is older than the wait, so an in-flight create is not yanked out
112
+ * from under the holder.
113
+ * Owner is read once: a second read can see a pid that appeared after an
114
+ * empty snapshot and would steal a live lock.
115
+ */
116
+ function lockIsStale(lockPath) {
117
+ const owner = readLockOwner(lockPath);
118
+ if (owner) {
119
+ if (owner.pid === process.pid)
120
+ return true;
121
+ if (!pidAlive(owner.pid))
122
+ return true;
123
+ return Date.now() - owner.at >= LOCK_STALE_MS;
124
+ }
125
+ try {
126
+ return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ function lockBusy(code) {
133
+ // Unix: O_EXCL on an existing file is EEXIST. Windows: a holder that still
134
+ // has the handle open (or a delete-pending name) is EPERM / EACCES / EBUSY.
135
+ return (code === "EEXIST" ||
136
+ code === "EPERM" ||
137
+ code === "EACCES" ||
138
+ code === "EBUSY");
139
+ }
140
+ function unlinkLock(lockPath) {
141
+ const until = Date.now() + 500;
142
+ while (true) {
143
+ try {
144
+ rmSync(lockPath, { force: true });
145
+ return;
146
+ }
147
+ catch (err) {
148
+ if (!lockBusy(err.code) || Date.now() >= until) {
149
+ return;
150
+ }
151
+ sleepSync(10);
152
+ }
153
+ }
154
+ }
155
+ function withFileLock(lockName, fn) {
81
156
  ensureDir();
82
- const lockPath = join(dir, SIGNALS_LOCK);
157
+ const lockPath = join(dir, lockName);
83
158
  const deadline = Date.now() + LOCK_WAIT_MS;
84
159
  while (true) {
85
160
  try {
86
161
  const fd = openSync(lockPath, "wx");
87
162
  try {
163
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
88
164
  return fn();
89
165
  }
90
166
  finally {
91
167
  closeSync(fd);
92
- rmSync(lockPath, { force: true });
168
+ unlinkLock(lockPath);
93
169
  }
94
170
  }
95
171
  catch (err) {
96
172
  const code = err.code;
97
- if (code !== "EEXIST")
173
+ if (!lockBusy(code))
98
174
  throw err;
175
+ if (lockIsStale(lockPath)) {
176
+ unlinkLock(lockPath);
177
+ continue;
178
+ }
99
179
  if (Date.now() >= deadline) {
100
- throw new Error(`timed out waiting for ${SIGNALS_LOCK}`);
180
+ throw new Error(`timed out waiting for ${lockName}`);
101
181
  }
102
182
  sleepSync(10);
103
183
  }
104
184
  }
105
185
  }
186
+ function withSignalsLock(fn) {
187
+ return withFileLock(SIGNALS_LOCK, fn);
188
+ }
106
189
  function readSignals() {
107
190
  return readJson(SIGNALS_FILE, {});
108
191
  }
@@ -136,31 +219,85 @@ export function createStateStore(options = {}) {
136
219
  return all[key] ?? null;
137
220
  }
138
221
  function writeCursor(key, text) {
139
- const all = readJson(CURSORS_FILE, {});
140
- all[key] = text;
141
- writeJson(CURSORS_FILE, all);
222
+ withFileLock(CURSORS_LOCK, () => {
223
+ const all = readJson(CURSORS_FILE, {});
224
+ all[key] = text;
225
+ writeJson(CURSORS_FILE, all);
226
+ });
142
227
  }
143
228
  function clearCursor(key) {
144
- const all = readJson(CURSORS_FILE, {});
145
- delete all[key];
146
- writeJson(CURSORS_FILE, all);
229
+ withFileLock(CURSORS_LOCK, () => {
230
+ const all = readJson(CURSORS_FILE, {});
231
+ delete all[key];
232
+ writeJson(CURSORS_FILE, all);
233
+ });
147
234
  }
148
- function readBus() {
149
- const value = readJson(BUS_FILE, null);
150
- if (!value || typeof value.plugin !== "string" || !value.plugin)
235
+ function asMarker(value) {
236
+ if (!value || typeof value !== "object")
237
+ return null;
238
+ const rec = value;
239
+ if (typeof rec.plugin !== "string" || !rec.plugin)
151
240
  return null;
152
- return value;
241
+ if (typeof rec.configKey !== "string" || !rec.configKey)
242
+ return null;
243
+ return {
244
+ plugin: rec.plugin,
245
+ configKey: rec.configKey,
246
+ installedAt: typeof rec.installedAt === "number" ? rec.installedAt : 0,
247
+ };
153
248
  }
154
- function writeBus(marker) {
155
- writeJson(BUS_FILE, marker);
249
+ /**
250
+ * Current `{ sessions: { <name>: marker } }` plus the pre-0.1.6 flat file
251
+ * `{ plugin, configKey, installedAt }`, which any session may inherit until
252
+ * the next write namespaces it.
253
+ */
254
+ function readBusFile() {
255
+ const raw = readJson(BUS_FILE, null);
256
+ if (!raw || typeof raw !== "object")
257
+ return { sessions: {}, legacy: null };
258
+ const sessions = {};
259
+ if (raw.sessions && typeof raw.sessions === "object") {
260
+ for (const [name, marker] of Object.entries(raw.sessions)) {
261
+ const parsed = asMarker(marker);
262
+ if (parsed)
263
+ sessions[name] = parsed;
264
+ }
265
+ }
266
+ return { sessions, legacy: asMarker(raw) };
156
267
  }
157
- function clearBus() {
158
- try {
159
- rmSync(join(dir, BUS_FILE), { force: true });
268
+ function readBus(session) {
269
+ if (!session)
270
+ return null;
271
+ const { sessions, legacy } = readBusFile();
272
+ return sessions[session] ?? legacy;
273
+ }
274
+ function writeBus(session, marker) {
275
+ const { sessions } = readBusFile();
276
+ sessions[session] = marker;
277
+ writeJson(BUS_FILE, { sessions });
278
+ }
279
+ function clearBus(session) {
280
+ if (!session) {
281
+ try {
282
+ rmSync(join(dir, BUS_FILE), { force: true });
283
+ }
284
+ catch {
285
+ // Nothing to forget.
286
+ }
287
+ return;
160
288
  }
161
- catch {
162
- // Nothing to forget.
289
+ const { sessions } = readBusFile();
290
+ delete sessions[session];
291
+ if (Object.keys(sessions).length === 0) {
292
+ try {
293
+ rmSync(join(dir, BUS_FILE), { force: true });
294
+ }
295
+ catch {
296
+ // Nothing to forget.
297
+ }
298
+ return;
163
299
  }
300
+ writeJson(BUS_FILE, { sessions });
164
301
  }
165
302
  /** Test helper: drop everything this store wrote. */
166
303
  function reset() {
@@ -47,6 +47,8 @@ export type WaitRequest = {
47
47
  idleMs?: number;
48
48
  timeoutMs?: number;
49
49
  pollMs?: number;
50
+ /** Read scrollback above the viewport, matching dump --full. */
51
+ full?: boolean;
50
52
  };
51
53
  /**
52
54
  * `regex` is deliberately absent: the plugin has no regex engine, refuses such
@@ -35,6 +35,7 @@ export function waitPayload(req) {
35
35
  idleMs: req.idleMs ?? 2000,
36
36
  timeoutMs: req.timeoutMs ?? 60000,
37
37
  pollMs: req.pollMs ?? 50,
38
+ full: req.full === true,
38
39
  });
39
40
  }
40
41
  /** Screens plus "did this move since you last asked", so status needs no gap. */
@@ -10,10 +10,12 @@ export declare function resolveZellijBinary(env?: NodeJS.ProcessEnv): string;
10
10
  export declare function sanitizeZellijEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
11
  /**
12
12
  * Remote crew: `ZSWARM_SSH=user@host` routes every zellij call over ssh.
13
- * `ZSWARM_SSH_OPTS` is split on whitespace; `BatchMode` keeps it non-interactive.
13
+ * `ZSWARM_SSH_OPTS` is split like a shell so quoted paths stay one arg;
14
+ * `BatchMode` keeps it non-interactive.
14
15
  * `ZSWARM_TMP` points at the interactive session's temp (or `auto` to discover).
15
16
  * `ZSWARM_SSH_MODE=interactive` runs each call in the Windows desktop session.
16
17
  */
18
+ export declare function parseSshOpts(raw: string): string[];
17
19
  export declare function resolveSshTarget(env?: NodeJS.ProcessEnv): SshTarget | null;
18
20
  /** Runner bound to the resolved zellij binary and a cleaned environment. */
19
21
  export declare function defaultExec(zellijPath: string, env: NodeJS.ProcessEnv): ZellijExecFn;
@@ -75,15 +75,47 @@ export function sanitizeZellijEnv(env) {
75
75
  }
76
76
  /**
77
77
  * Remote crew: `ZSWARM_SSH=user@host` routes every zellij call over ssh.
78
- * `ZSWARM_SSH_OPTS` is split on whitespace; `BatchMode` keeps it non-interactive.
78
+ * `ZSWARM_SSH_OPTS` is split like a shell so quoted paths stay one arg;
79
+ * `BatchMode` keeps it non-interactive.
79
80
  * `ZSWARM_TMP` points at the interactive session's temp (or `auto` to discover).
80
81
  * `ZSWARM_SSH_MODE=interactive` runs each call in the Windows desktop session.
81
82
  */
83
+ export function parseSshOpts(raw) {
84
+ const out = [];
85
+ let cur = "";
86
+ let quote = null;
87
+ for (let i = 0; i < raw.length; i++) {
88
+ const ch = raw[i];
89
+ if (quote) {
90
+ if (ch === quote) {
91
+ quote = null;
92
+ }
93
+ else {
94
+ cur += ch;
95
+ }
96
+ }
97
+ else if (ch === '"' || ch === "'") {
98
+ quote = ch;
99
+ }
100
+ else if (/\s/.test(ch)) {
101
+ if (cur.length > 0) {
102
+ out.push(cur);
103
+ cur = "";
104
+ }
105
+ }
106
+ else {
107
+ cur += ch;
108
+ }
109
+ }
110
+ if (cur.length > 0)
111
+ out.push(cur);
112
+ return out;
113
+ }
82
114
  export function resolveSshTarget(env = process.env) {
83
115
  const host = env.ZSWARM_SSH?.trim();
84
116
  if (!host)
85
117
  return null;
86
- const options = (env.ZSWARM_SSH_OPTS ?? "").trim().split(/\s+/).filter(Boolean);
118
+ const options = parseSshOpts(env.ZSWARM_SSH_OPTS ?? "");
87
119
  if (!options.some((o) => o.startsWith("BatchMode"))) {
88
120
  options.unshift("-o", "BatchMode=yes");
89
121
  }
@@ -67,6 +67,15 @@ export declare function resolveBusPlugin(env?: NodeJS.ProcessEnv): string | null
67
67
  export declare function busPluginUrl(pluginPath: string): string;
68
68
  /** zswarm-bus → zswarm-bus-2 → zswarm-bus-3: a fresh pipe destination each time. */
69
69
  export declare function nextConfigKey(key: string): string;
70
+ /**
71
+ * Plugin panes belonging to this bus — never tab-bar / status-bar / room.
72
+ * Titles are the `file:` URL (or the wasm basename) Zellij shows for the pane.
73
+ */
74
+ export declare function isBusPluginPane(pane: {
75
+ isPlugin?: boolean;
76
+ title?: string;
77
+ command?: string | null;
78
+ }, pluginPath?: string | null): boolean;
70
79
  /**
71
80
  * Pull the plugin's answer out of the pipe. Zellij interleaves its own notices
72
81
  * ("Action CliPipe did not complete within 1s timeout" shows up on successful
@@ -140,6 +140,26 @@ export function nextConfigKey(key) {
140
140
  return `${key}-2`;
141
141
  return `${match[1]}-${Number(match[2]) + 1}`;
142
142
  }
143
+ /**
144
+ * Plugin panes belonging to this bus — never tab-bar / status-bar / room.
145
+ * Titles are the `file:` URL (or the wasm basename) Zellij shows for the pane.
146
+ */
147
+ export function isBusPluginPane(pane, pluginPath) {
148
+ if (!pane.isPlugin)
149
+ return false;
150
+ const hay = `${pane.title ?? ""}\n${pane.command ?? ""}`
151
+ .replace(/\\/g, "/")
152
+ .toLowerCase();
153
+ if (hay.includes("zswarm-bus") || hay.includes("zswarm-events"))
154
+ return true;
155
+ if (!pluginPath)
156
+ return false;
157
+ const normalized = pluginPath.replace(/\\/g, "/").toLowerCase();
158
+ if (normalized && hay.includes(normalized))
159
+ return true;
160
+ const base = normalized.split("/").pop();
161
+ return Boolean(base && hay.includes(base));
162
+ }
143
163
  function toSnapshot(value) {
144
164
  if (value.ok !== true || !Array.isArray(value.panes))
145
165
  return null;
@@ -8,6 +8,7 @@ export type ZellijClientOptions = {
8
8
  zellijPath?: string;
9
9
  timeoutMs?: number;
10
10
  env?: NodeJS.ProcessEnv;
11
+ signal?: AbortSignal;
11
12
  };
12
13
  /** Thin, stateless wrapper over the `zellij` binary. */
13
14
  export declare function createZellijClient(options?: ZellijClientOptions): {
@@ -5,7 +5,7 @@ import { parseTabList, resolveTab } from "./tabs.js";
5
5
  import { createSshExec } from "../exec.js";
6
6
  import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
7
7
  import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
8
- import { parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
8
+ import { isZellijNoSessionsOutput, parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
9
9
  /** Thin, stateless wrapper over the `zellij` binary. */
10
10
  export function createZellijClient(options = {}) {
11
11
  const env = options.env ?? process.env;
@@ -13,10 +13,11 @@ export function createZellijClient(options = {}) {
13
13
  const ssh = options.exec ? null : resolveSshTarget(env);
14
14
  const zellijPath = options.zellijPath ??
15
15
  (ssh ? `ssh://${ssh.host}/${ssh.remoteBin}` : resolveZellijBinary(env));
16
- const exec = options.exec ??
16
+ const rawExec = options.exec ??
17
17
  (ssh
18
18
  ? createSshExec(ssh, sanitizeZellijEnv(env))
19
19
  : defaultExec(zellijPath, env));
20
+ const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
20
21
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
21
22
  const selfPaneId = resolveSelfPaneId(env);
22
23
  async function run(args, label) {
@@ -30,7 +31,20 @@ export function createZellijClient(options = {}) {
30
31
  return result;
31
32
  }
32
33
  async function listSessions() {
33
- const result = await run(["list-sessions", "--short", "--no-formatting"], "zellij list-sessions");
34
+ const result = await exec(["list-sessions", "--short", "--no-formatting"], {
35
+ timeoutMs,
36
+ });
37
+ if (result.code === NOT_FOUND_EXIT) {
38
+ throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
39
+ }
40
+ if (result.code !== 0) {
41
+ // Nonzero here is Zellij's normal "none running", not a crash. Empty
42
+ // lets resolveSession throw zellij_no_session so unworktree can treat
43
+ // it as no occupants instead of zellij_failed.
44
+ if (isZellijNoSessionsOutput(result.stdout, result.stderr))
45
+ return [];
46
+ throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
47
+ }
34
48
  return parseSessionList(result.stdout);
35
49
  }
36
50
  async function resolveSession(explicit) {
@@ -8,6 +8,8 @@ export type ZellijSessionResolve = {
8
8
  */
9
9
  export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
10
10
  export declare function parseSessionList(stdout: string): string[];
11
+ /** Zellij exits 1 and prints this when the machine simply has no sessions. */
12
+ export declare function isZellijNoSessionsOutput(stdout: string, stderr: string): boolean;
11
13
  /** Session from an explicit argument or the environment, before asking Zellij. */
12
14
  export declare function sessionFromEnv(env: NodeJS.ProcessEnv, explicit?: string | null): ZellijSessionResolve | null;
13
15
  /** Pick a session when the environment gave no answer. */
@@ -19,6 +19,10 @@ export function parseSessionList(stdout) {
19
19
  .map((l) => l.trim())
20
20
  .filter(Boolean);
21
21
  }
22
+ /** Zellij exits 1 and prints this when the machine simply has no sessions. */
23
+ export function isZellijNoSessionsOutput(stdout, stderr) {
24
+ return /no active zellij sessions found/i.test(`${stderr}\n${stdout}`);
25
+ }
22
26
  /** Session from an explicit argument or the environment, before asking Zellij. */
23
27
  export function sessionFromEnv(env, explicit) {
24
28
  const arg = explicit?.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@zswarm/wasm": "0.1.1"
28
+ "@zswarm/wasm": "0.1.2"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json",