@zswarm/core 0.1.2 → 0.1.4

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/ops/serve.js CHANGED
@@ -4,27 +4,60 @@ import { ZellijError } from "../errors.js";
4
4
  import { encodePowerShellCommand } from "../zellij/ipc.js";
5
5
  export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
6
6
  export const SERVE_TASK_NAME = "zswarm-serve";
7
+ /** One JSONL request; a client that never sends newline cannot grow forever. */
8
+ export const SERVE_MAX_REQUEST_BYTES = 1024 * 1024;
9
+ /** Drop a socket that never finishes a JSONL line. */
10
+ export const SERVE_IDLE_TIMEOUT_MS = 30_000;
11
+ /** Concurrent TCP clients, including in-flight wait ops. */
12
+ export const SERVE_MAX_CONNECTIONS = 32;
13
+ /** Wait/await allow 15 minutes; this is that plus slack. */
14
+ export const SERVE_CALL_TIMEOUT_CAP_MS = 16 * 60_000;
15
+ const SERVE_TOKEN_FIELD = "serveToken";
16
+ export function isLoopbackHost(host) {
17
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
18
+ return (h === "127.0.0.1" ||
19
+ h === "localhost" ||
20
+ h === "::1" ||
21
+ h === "0:0:0:0:0:0:0:1");
22
+ }
23
+ export function formatListenLabel(host, port) {
24
+ return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
25
+ }
7
26
  export function parseListenAddress(raw) {
8
27
  const text = (raw ?? DEFAULT_SERVE_LISTEN).trim() || DEFAULT_SERVE_LISTEN;
9
28
  const stripped = text.replace(/^tcp:\/\//i, "");
10
- const hostPort = stripped.includes("]")
11
- ? stripped
12
- : stripped.includes(":")
29
+ let host;
30
+ let port;
31
+ if (stripped.startsWith("[")) {
32
+ const end = stripped.indexOf("]");
33
+ if (end === -1) {
34
+ throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
35
+ }
36
+ host = stripped.slice(1, end);
37
+ const rest = stripped.slice(end + 1);
38
+ if (!rest.startsWith(":")) {
39
+ throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
40
+ }
41
+ port = Number(rest.slice(1));
42
+ }
43
+ else {
44
+ const hostPort = stripped.includes(":")
13
45
  ? stripped
14
46
  : `127.0.0.1:${stripped}`;
15
- const colon = hostPort.lastIndexOf(":");
16
- const host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
17
- const port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
18
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
47
+ const colon = hostPort.lastIndexOf(":");
48
+ host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
49
+ port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
50
+ }
51
+ if (!host || !Number.isInteger(port) || port < 0 || port > 65535) {
19
52
  throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
20
53
  }
21
- return { host, port, label: `${host}:${port}` };
54
+ return { host, port, label: formatListenLabel(host, port) };
22
55
  }
23
- /** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
56
+ /** Client-side wait: the op's own timeout plus slack. */
24
57
  export function serveCallTimeout(args) {
25
58
  const requested = Number(args.timeoutMs);
26
59
  const base = Number.isFinite(requested) && requested > 0 ? requested : 60_000;
27
- return Math.min(Math.max(base + 5_000, 15_000), 10 * 60_000);
60
+ return Math.min(Math.max(base + 5_000, 15_000), SERVE_CALL_TIMEOUT_CAP_MS);
28
61
  }
29
62
  /** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
30
63
  export function serveChildEnv(env) {
@@ -33,34 +66,114 @@ export function serveChildEnv(env) {
33
66
  delete out.ZSWARM_SSH;
34
67
  return out;
35
68
  }
36
- export function startServe(listen, dispatch) {
69
+ function unauthorized() {
70
+ return {
71
+ ok: false,
72
+ error: {
73
+ code: "serve_unauthorized",
74
+ message: "zswarm serve rejected the request (missing or wrong ZSWARM_SERVE_TOKEN)",
75
+ },
76
+ };
77
+ }
78
+ function takeServeToken(args) {
79
+ const token = typeof args[SERVE_TOKEN_FIELD] === "string"
80
+ ? args[SERVE_TOKEN_FIELD]
81
+ : undefined;
82
+ const request = { ...args };
83
+ delete request[SERVE_TOKEN_FIELD];
84
+ return { token, request };
85
+ }
86
+ export function startServe(listen, dispatch, options = {}) {
37
87
  const { host, port } = parseListenAddress(listen);
88
+ if (!isLoopbackHost(host)) {
89
+ return Promise.reject(new ZellijError("serve_auth", `zswarm serve only listens on loopback (127.0.0.1 / ::1); off-machine access is an SSH tunnel to 127.0.0.1 (${host} refused)`));
90
+ }
91
+ const token = options.token?.trim() || undefined;
92
+ if (!token) {
93
+ return Promise.reject(new ZellijError("serve_auth", "zswarm serve requires ZSWARM_SERVE_TOKEN; another local OS user can connect to 127.0.0.1"));
94
+ }
95
+ const maxRequestBytes = options.maxRequestBytes ?? SERVE_MAX_REQUEST_BYTES;
96
+ const idleTimeoutMs = options.idleTimeoutMs ?? SERVE_IDLE_TIMEOUT_MS;
97
+ const maxConnections = options.maxConnections ?? SERVE_MAX_CONNECTIONS;
38
98
  return new Promise((resolve, reject) => {
99
+ const sockets = new Set();
39
100
  const server = createServer((socket) => {
101
+ socket.on("error", () => {
102
+ socket.destroy();
103
+ });
104
+ if (sockets.size >= maxConnections) {
105
+ socket.destroy();
106
+ return;
107
+ }
108
+ sockets.add(socket);
109
+ socket.on("close", () => {
110
+ sockets.delete(socket);
111
+ });
40
112
  let buf = "";
113
+ let draining = false;
41
114
  socket.setEncoding("utf8");
115
+ socket.setTimeout(idleTimeoutMs);
116
+ socket.on("timeout", () => {
117
+ socket.destroy();
118
+ });
42
119
  socket.on("data", (chunk) => {
43
120
  buf += chunk;
121
+ if (buf.length > maxRequestBytes) {
122
+ socket.write(`${JSON.stringify({
123
+ ok: false,
124
+ error: {
125
+ code: "bad_arg",
126
+ message: `serve request exceeded ${maxRequestBytes} bytes`,
127
+ },
128
+ })}\n`);
129
+ socket.destroy();
130
+ return;
131
+ }
44
132
  void drain();
45
133
  });
46
134
  async function drain() {
47
- let nl = buf.indexOf("\n");
48
- while (nl !== -1) {
49
- const line = buf.slice(0, nl).trim();
50
- buf = buf.slice(nl + 1);
51
- if (line) {
52
- let result;
53
- try {
54
- const args = JSON.parse(line);
55
- result = await dispatch(args);
135
+ if (draining)
136
+ return;
137
+ draining = true;
138
+ socket.setTimeout(0);
139
+ try {
140
+ let nl = buf.indexOf("\n");
141
+ while (nl !== -1) {
142
+ const line = buf.slice(0, nl).trim();
143
+ buf = buf.slice(nl + 1);
144
+ if (line) {
145
+ let result;
146
+ try {
147
+ const parsed = JSON.parse(line);
148
+ const taken = takeServeToken(parsed);
149
+ if (taken.token !== token) {
150
+ result = unauthorized();
151
+ }
152
+ else {
153
+ result = await dispatch(taken.request);
154
+ }
155
+ }
156
+ catch (err) {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ result = { ok: false, error: { code: "bad_arg", message } };
159
+ }
160
+ if (!socket.destroyed) {
161
+ socket.write(`${JSON.stringify(result)}\n`);
162
+ }
56
163
  }
57
- catch (err) {
58
- const message = err instanceof Error ? err.message : String(err);
59
- result = { ok: false, error: { code: "bad_arg", message } };
60
- }
61
- socket.write(`${JSON.stringify(result)}\n`);
164
+ nl = buf.indexOf("\n");
165
+ }
166
+ }
167
+ finally {
168
+ draining = false;
169
+ if (socket.destroyed)
170
+ return;
171
+ if (buf.includes("\n")) {
172
+ void drain();
173
+ }
174
+ else {
175
+ socket.setTimeout(idleTimeoutMs);
62
176
  }
63
- nl = buf.indexOf("\n");
64
177
  }
65
178
  }
66
179
  });
@@ -69,8 +182,10 @@ export function startServe(listen, dispatch) {
69
182
  const addr = server.address();
70
183
  const actualPort = typeof addr === "object" && addr ? addr.port : port;
71
184
  resolve({
72
- label: `${host}:${actualPort}`,
185
+ label: formatListenLabel(host, actualPort),
73
186
  close: () => new Promise((done, fail) => {
187
+ for (const open of sockets)
188
+ open.destroy();
74
189
  server.close((err) => (err ? fail(err) : done()));
75
190
  }),
76
191
  });
@@ -80,8 +195,11 @@ export function startServe(listen, dispatch) {
80
195
  export function parseServeTarget(raw) {
81
196
  return parseListenAddress(raw);
82
197
  }
83
- export function callServe(target, args, timeoutMs = 15_000) {
198
+ export function callServe(target, args, timeoutMs = 15_000, token) {
84
199
  const { host, port } = parseServeTarget(target);
200
+ const payload = token && token.trim()
201
+ ? { ...args, [SERVE_TOKEN_FIELD]: token.trim() }
202
+ : args;
85
203
  return new Promise((resolve) => {
86
204
  const socket = connect({ host, port });
87
205
  let buf = "";
@@ -130,12 +248,28 @@ export function callServe(target, args, timeoutMs = 15_000) {
130
248
  }
131
249
  });
132
250
  socket.on("connect", () => {
133
- socket.write(`${JSON.stringify(args)}\n`);
251
+ socket.write(`${JSON.stringify(payload)}\n`);
134
252
  });
135
253
  });
136
254
  }
137
- export function serveLogonCommand(execPath, scriptPath, listen) {
138
- return `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
255
+ export function serveLogonCommand(execPath, scriptPath, listen, token) {
256
+ const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
257
+ const secret = token?.trim();
258
+ if (!secret)
259
+ return launch;
260
+ if (/[\0\r\n"%]/.test(secret)) {
261
+ throw new ZellijError("bad_arg", "ZSWARM_SERVE_TOKEN must not contain quotes, newlines, or %");
262
+ }
263
+ return `set "ZSWARM_SERVE_TOKEN=${secret}"&& ${launch}`;
264
+ }
265
+ function serveInstallToken(input) {
266
+ const raw = input.token ??
267
+ (input.env ? input.env.ZSWARM_SERVE_TOKEN : process.env.ZSWARM_SERVE_TOKEN);
268
+ const secret = raw?.trim() ?? "";
269
+ if (!secret) {
270
+ throw new ZellijError("serve_auth", "zswarm serve requires ZSWARM_SERVE_TOKEN; another local OS user can connect to 127.0.0.1");
271
+ }
272
+ return secret;
139
273
  }
140
274
  function runPowerShell(script) {
141
275
  return new Promise((resolve) => {
@@ -163,13 +297,17 @@ export async function installServeLogon(input) {
163
297
  if ((input.platform ?? process.platform) !== "win32") {
164
298
  throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
165
299
  }
166
- const { label } = parseListenAddress(input.listen);
300
+ const { host, label } = parseListenAddress(input.listen);
301
+ if (!isLoopbackHost(host)) {
302
+ throw new ZellijError("serve_auth", `zswarm serve only listens on loopback (127.0.0.1 / ::1); off-machine access is an SSH tunnel to 127.0.0.1 (${host} refused)`);
303
+ }
304
+ const token = serveInstallToken(input);
167
305
  const execPath = input.execPath ?? process.execPath;
168
306
  const scriptPath = input.scriptPath ?? process.argv[1];
169
307
  if (!scriptPath) {
170
308
  throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
171
309
  }
172
- const command = serveLogonCommand(execPath, scriptPath, label);
310
+ const command = serveLogonCommand(execPath, scriptPath, label, token);
173
311
  const commandB64 = Buffer.from(command, "utf8").toString("base64");
174
312
  const script = [
175
313
  "$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
@@ -191,7 +329,19 @@ export async function uninstallServeLogon(input = {}) {
191
329
  if ((input.platform ?? process.platform) !== "win32") {
192
330
  throw new ZellijError("usage", "serve --clear is Windows-only");
193
331
  }
194
- const script = `Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue`;
195
- await runPowerShell(script);
332
+ const script = [
333
+ "$ProgressPreference = 'SilentlyContinue'",
334
+ "try {",
335
+ ` Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false`,
336
+ "} catch {",
337
+ " if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { exit 0 }",
338
+ " [Console]::Error.Write(($_ | Out-String).Trim())",
339
+ " exit 1",
340
+ "}",
341
+ ].join("\n");
342
+ const result = await runPowerShell(script);
343
+ if (result.code !== 0) {
344
+ throw new ZellijError("zellij_failed", `serve --clear failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
345
+ }
196
346
  return { task: SERVE_TASK_NAME, cleared: true };
197
347
  }
@@ -120,8 +120,15 @@ export async function removePeerWorktree(git, client, args, env = process.env) {
120
120
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
121
121
  occupants = panesIn(await client.listPanes(session), target.path);
122
122
  }
123
- catch {
124
- // No live session means nobody is working in it.
123
+ catch (err) {
124
+ // No live session means nobody is working in it. Any other failure must
125
+ // not look like "empty" — that is how a clean busy worktree gets deleted.
126
+ if (err instanceof ZellijError && err.code === "zellij_no_session") {
127
+ occupants = [];
128
+ }
129
+ else {
130
+ throw err;
131
+ }
125
132
  }
126
133
  if (occupants.length > 0 && !force) {
127
134
  throw new ZellijError("worktree_busy", `${occupants.join(", ")} still working in ${target.path}; close the pane or pass force=true`);
package/dist/policy.js CHANGED
@@ -7,6 +7,12 @@ const WRITE_OPS = new Set([
7
7
  "spawn",
8
8
  "close",
9
9
  "unworktree",
10
+ "checkpoint",
11
+ "signal",
12
+ "rename",
13
+ "focus",
14
+ "stack",
15
+ "serve",
10
16
  ]);
11
17
  /** Explicit disable tokens for ZSWARM_ALLOW_* switches. */
12
18
  function isDisabled(raw) {
package/dist/schema.js CHANGED
@@ -150,7 +150,7 @@ export const PARAMS = [
150
150
  name: "from",
151
151
  type: "string",
152
152
  flags: ["--from", "-f"],
153
- description: "send: sender label in the [zswarm from=…] prefix",
153
+ description: "send: sender label in the [zswarm from=…] prefix (default: ZSWARM_FROM, else the sending pane's title, else swarm)",
154
154
  },
155
155
  {
156
156
  name: "raw",
@@ -447,7 +447,7 @@ export function cliUsage() {
447
447
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
448
448
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
449
449
  }
450
- 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, then list and status use it now.", "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.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE", "");
450
+ 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, then list and status use it now.", "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 (+ ZSWARM_SERVE_TOKEN off loopback).", "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_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN", "");
451
451
  return lines.join("\n");
452
452
  }
453
453
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
@@ -465,10 +465,22 @@ function extractOp(argv) {
465
465
  return { op: first, rest: argv.slice(1) };
466
466
  }
467
467
  const names = OP_NAMES;
468
- const at = argv.findIndex((token) => names.includes(token));
469
- if (at === -1)
470
- return { op: first ?? "", rest: argv.slice(1) };
471
- return { op: argv[at], rest: [...argv.slice(0, at), ...argv.slice(at + 1)] };
468
+ for (let i = 0; i < argv.length; i++) {
469
+ const token = argv[i];
470
+ if (token.startsWith("-")) {
471
+ const param = FLAG_INDEX.get(token);
472
+ if (param && param.type !== "boolean")
473
+ i += 1;
474
+ continue;
475
+ }
476
+ if (names.includes(token)) {
477
+ return {
478
+ op: token,
479
+ rest: [...argv.slice(0, i), ...argv.slice(i + 1)],
480
+ };
481
+ }
482
+ }
483
+ return { op: first ?? "", rest: argv.slice(1) };
472
484
  }
473
485
  export function parseCliArgv(argv) {
474
486
  const { op, rest } = extractOp(argv);
package/dist/state.js CHANGED
@@ -1,12 +1,18 @@
1
- import { appendFileSync, mkdirSync, 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
+ const SIGNALS_LOCK = "signals.lock";
6
7
  const CURSORS_FILE = "cursors.json";
8
+ const CURSORS_LOCK = "cursors.lock";
7
9
  const BUS_FILE = "bus.json";
8
10
  /** Keeps the log bounded without needing a rotation daemon. */
9
11
  const LOG_TAIL_BYTES = 512 * 1024;
12
+ const LOCK_WAIT_MS = 5_000;
13
+ function sleepSync(ms) {
14
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
15
+ }
10
16
  export function defaultStateDir(env = process.env) {
11
17
  const explicit = env.ZSWARM_STATE_DIR?.trim();
12
18
  if (explicit)
@@ -72,43 +78,149 @@ export function createStateStore(options = {}) {
72
78
  }
73
79
  return out;
74
80
  }
81
+ function pidAlive(pid) {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ }
86
+ catch (err) {
87
+ // ESRCH: gone. EPERM: exists, just unsignalable — still a live holder.
88
+ return err.code === "EPERM";
89
+ }
90
+ }
91
+ /** Owner recorded in the lock file so a crash can be distinguished from a live holder. */
92
+ function readLockOwner(lockPath) {
93
+ try {
94
+ const rec = JSON.parse(readFileSync(lockPath, "utf8"));
95
+ if (typeof rec.pid !== "number" || !Number.isInteger(rec.pid) || rec.pid <= 0)
96
+ return null;
97
+ const at = typeof rec.at === "number" ? rec.at : 0;
98
+ return { pid: rec.pid, at };
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /**
105
+ * Dead pid → steal now. Empty leftover from older writers (wx with no owner
106
+ * bytes) → steal once mtime is older than the wait, so an in-flight create is
107
+ * not yanked out from under the holder.
108
+ * Owner is read once: a second read can see a pid that appeared after an
109
+ * empty snapshot and would steal a live lock.
110
+ */
111
+ function lockIsStale(lockPath) {
112
+ const owner = readLockOwner(lockPath);
113
+ if (owner)
114
+ return !pidAlive(owner.pid);
115
+ try {
116
+ return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ function lockBusy(code) {
123
+ // Unix: O_EXCL on an existing file is EEXIST. Windows: a holder that still
124
+ // has the handle open (or a delete-pending name) is EPERM / EACCES / EBUSY.
125
+ return (code === "EEXIST" ||
126
+ code === "EPERM" ||
127
+ code === "EACCES" ||
128
+ code === "EBUSY");
129
+ }
130
+ function unlinkLock(lockPath) {
131
+ const until = Date.now() + 500;
132
+ while (true) {
133
+ try {
134
+ rmSync(lockPath, { force: true });
135
+ return;
136
+ }
137
+ catch (err) {
138
+ if (!lockBusy(err.code) || Date.now() >= until) {
139
+ return;
140
+ }
141
+ sleepSync(10);
142
+ }
143
+ }
144
+ }
145
+ function withFileLock(lockName, fn) {
146
+ ensureDir();
147
+ const lockPath = join(dir, lockName);
148
+ const deadline = Date.now() + LOCK_WAIT_MS;
149
+ while (true) {
150
+ try {
151
+ const fd = openSync(lockPath, "wx");
152
+ try {
153
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
154
+ return fn();
155
+ }
156
+ finally {
157
+ closeSync(fd);
158
+ unlinkLock(lockPath);
159
+ }
160
+ }
161
+ catch (err) {
162
+ const code = err.code;
163
+ if (!lockBusy(code))
164
+ throw err;
165
+ if (code === "EEXIST" && lockIsStale(lockPath)) {
166
+ unlinkLock(lockPath);
167
+ continue;
168
+ }
169
+ if (Date.now() >= deadline) {
170
+ throw new Error(`timed out waiting for ${lockName}`);
171
+ }
172
+ sleepSync(10);
173
+ }
174
+ }
175
+ }
176
+ function withSignalsLock(fn) {
177
+ return withFileLock(SIGNALS_LOCK, fn);
178
+ }
75
179
  function readSignals() {
76
180
  return readJson(SIGNALS_FILE, {});
77
181
  }
78
182
  function postSignal(channel, payload, at) {
79
- const all = readSignals();
80
- const prev = all[channel];
81
- const next = {
82
- count: (prev?.count ?? 0) + 1,
83
- at,
84
- last: payload,
85
- };
86
- all[channel] = next;
87
- writeJson(SIGNALS_FILE, all);
88
- return next;
183
+ return withSignalsLock(() => {
184
+ const all = readSignals();
185
+ const prev = all[channel];
186
+ const next = {
187
+ count: (prev?.count ?? 0) + 1,
188
+ at,
189
+ last: payload,
190
+ };
191
+ all[channel] = next;
192
+ writeJson(SIGNALS_FILE, all);
193
+ return next;
194
+ });
89
195
  }
90
196
  function clearSignal(channel) {
91
- if (channel === null) {
92
- writeJson(SIGNALS_FILE, {});
93
- return;
94
- }
95
- const all = readSignals();
96
- delete all[channel];
97
- writeJson(SIGNALS_FILE, all);
197
+ withSignalsLock(() => {
198
+ if (channel === null) {
199
+ writeJson(SIGNALS_FILE, {});
200
+ return;
201
+ }
202
+ const all = readSignals();
203
+ delete all[channel];
204
+ writeJson(SIGNALS_FILE, all);
205
+ });
98
206
  }
99
207
  function readCursor(key) {
100
208
  const all = readJson(CURSORS_FILE, {});
101
209
  return all[key] ?? null;
102
210
  }
103
211
  function writeCursor(key, text) {
104
- const all = readJson(CURSORS_FILE, {});
105
- all[key] = text;
106
- writeJson(CURSORS_FILE, all);
212
+ withFileLock(CURSORS_LOCK, () => {
213
+ const all = readJson(CURSORS_FILE, {});
214
+ all[key] = text;
215
+ writeJson(CURSORS_FILE, all);
216
+ });
107
217
  }
108
218
  function clearCursor(key) {
109
- const all = readJson(CURSORS_FILE, {});
110
- delete all[key];
111
- writeJson(CURSORS_FILE, all);
219
+ withFileLock(CURSORS_LOCK, () => {
220
+ const all = readJson(CURSORS_FILE, {});
221
+ delete all[key];
222
+ writeJson(CURSORS_FILE, all);
223
+ });
112
224
  }
113
225
  function readBus() {
114
226
  const value = readJson(BUS_FILE, null);
@@ -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
  }
@@ -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;
@@ -30,7 +30,20 @@ export function createZellijClient(options = {}) {
30
30
  return result;
31
31
  }
32
32
  async function listSessions() {
33
- const result = await run(["list-sessions", "--short", "--no-formatting"], "zellij list-sessions");
33
+ const result = await exec(["list-sessions", "--short", "--no-formatting"], {
34
+ timeoutMs,
35
+ });
36
+ if (result.code === NOT_FOUND_EXIT) {
37
+ throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
38
+ }
39
+ if (result.code !== 0) {
40
+ // Nonzero here is Zellij's normal "none running", not a crash. Empty
41
+ // lets resolveSession throw zellij_no_session so unworktree can treat
42
+ // it as no occupants instead of zellij_failed.
43
+ if (isZellijNoSessionsOutput(result.stdout, result.stderr))
44
+ return [];
45
+ throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
46
+ }
34
47
  return parseSessionList(result.stdout);
35
48
  }
36
49
  async function resolveSession(explicit) {