@zswarm/core 0.1.4 → 0.1.6

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.
Files changed (45) hide show
  1. package/README.md +4 -1
  2. package/dist/exec.d.ts +14 -1
  3. package/dist/exec.js +107 -14
  4. package/dist/harness.js +12 -7
  5. package/dist/index.d.ts +11 -10
  6. package/dist/index.js +8 -8
  7. package/dist/ops/bus.d.ts +16 -10
  8. package/dist/ops/bus.js +164 -98
  9. package/dist/ops/delivery.d.ts +3 -3
  10. package/dist/ops/delivery.js +4 -7
  11. package/dist/ops/dispatch.d.ts +7 -1
  12. package/dist/ops/dispatch.js +208 -68
  13. package/dist/ops/guards.d.ts +2 -0
  14. package/dist/ops/guards.js +11 -1
  15. package/dist/ops/observation.d.ts +8 -0
  16. package/dist/ops/observation.js +26 -0
  17. package/dist/ops/routing.d.ts +13 -0
  18. package/dist/ops/routing.js +21 -0
  19. package/dist/ops/serve.d.ts +1 -0
  20. package/dist/ops/serve.js +7 -1
  21. package/dist/ops/spawn.d.ts +3 -3
  22. package/dist/ops/spawn.js +130 -64
  23. package/dist/ops/status.d.ts +30 -5
  24. package/dist/ops/status.js +231 -91
  25. package/dist/ops/types.d.ts +7 -1
  26. package/dist/ops/util.d.ts +4 -0
  27. package/dist/ops/util.js +7 -1
  28. package/dist/ops/wait.d.ts +8 -1
  29. package/dist/ops/wait.js +7 -3
  30. package/dist/schema.d.ts +2 -0
  31. package/dist/schema.js +50 -8
  32. package/dist/state.d.ts +7 -6
  33. package/dist/state.js +77 -17
  34. package/dist/zellij/args.d.ts +4 -0
  35. package/dist/zellij/args.js +1 -0
  36. package/dist/zellij/binary.d.ts +34 -0
  37. package/dist/zellij/binary.js +169 -8
  38. package/dist/zellij/bus.d.ts +11 -0
  39. package/dist/zellij/bus.js +22 -1
  40. package/dist/zellij/client.d.ts +23 -5
  41. package/dist/zellij/client.js +99 -33
  42. package/dist/zellij/panes.js +3 -0
  43. package/dist/zellij/session.d.ts +19 -3
  44. package/dist/zellij/session.js +39 -9
  45. package/package.json +3 -3
package/dist/schema.js CHANGED
@@ -42,6 +42,9 @@ export const TARGET_OPS = [
42
42
  "keys",
43
43
  "interrupt",
44
44
  "close",
45
+ "rename",
46
+ "focus",
47
+ "stack",
45
48
  ];
46
49
  export const PARAMS = [
47
50
  {
@@ -60,7 +63,25 @@ export const PARAMS = [
60
63
  name: "all",
61
64
  type: "boolean",
62
65
  flags: ["--all", "-a"],
63
- description: "broadcast: every terminal pane in the session",
66
+ description: "broadcast: every terminal pane in the session; sessions: include EXITED resurrectable sessions",
67
+ },
68
+ {
69
+ name: "live",
70
+ type: "boolean",
71
+ flags: ["--live", "--active"],
72
+ description: "sessions: only live (non-EXITED) sessions — this is the default; use --all to include EXITED",
73
+ },
74
+ {
75
+ name: "local",
76
+ type: "boolean",
77
+ flags: ["--local"],
78
+ description: "route this call to the local machine only — clears ZSWARM_SSH, ZSWARM_SERVE, and remote ZSWARM_TMP for the invocation",
79
+ },
80
+ {
81
+ name: "ssh",
82
+ type: "string",
83
+ flags: ["--ssh"],
84
+ description: "route this call over SSH to user@host (or an alias) for the invocation; clears ZSWARM_SERVE; put flags in ZSWARM_SSH_OPTS",
64
85
  },
65
86
  {
66
87
  name: "group",
@@ -140,6 +161,13 @@ export const PARAMS = [
140
161
  flags: ["--body", "-b", "--text"],
141
162
  description: "send: message body",
142
163
  },
164
+ {
165
+ name: "bodyFile",
166
+ type: "string",
167
+ flags: ["--body-file"],
168
+ cliOnly: true,
169
+ description: "send: read UTF-8 on the caller from PATH, or - for stdin; cannot combine with --body/--text",
170
+ },
143
171
  {
144
172
  name: "text",
145
173
  type: "string",
@@ -217,7 +245,7 @@ export const PARAMS = [
217
245
  name: "timeoutMs",
218
246
  type: "number",
219
247
  flags: ["--timeout-ms"],
220
- description: "wait: give up after this long (default 60000)",
248
+ description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000), including setup and observation",
221
249
  },
222
250
  {
223
251
  name: "keys",
@@ -299,6 +327,12 @@ export const PARAMS = [
299
327
  values: ["auto", "double-enter", "none"],
300
328
  description: "send/broadcast: auto verifies the paste actually submitted and presses Enter again if not (default)",
301
329
  },
330
+ {
331
+ name: "observeMs",
332
+ type: "number",
333
+ flags: ["--observe-ms"],
334
+ description: "spawn: observe creation/alias for up to 3000ms; pane lookup: retry absence for 1000ms; 0 disables retries",
335
+ },
302
336
  {
303
337
  name: "settleMs",
304
338
  type: "number",
@@ -309,7 +343,7 @@ export const PARAMS = [
309
343
  name: "expect",
310
344
  type: "string",
311
345
  flags: ["--expect"],
312
- description: "text the target pane's screen must contain before zswarm will write to it",
346
+ description: "send/keys/interrupt: case-insensitive substring required on the current screen immediately before input",
313
347
  },
314
348
  {
315
349
  name: "message",
@@ -382,7 +416,7 @@ export const PARAMS = [
382
416
  name: "force",
383
417
  type: "boolean",
384
418
  flags: ["--force"],
385
- description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: reinstall under a fresh key",
419
+ description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: close orphan bus panes and reload this session's plugin",
386
420
  },
387
421
  {
388
422
  name: "listen",
@@ -420,8 +454,10 @@ export function mcpInputSchema() {
420
454
  description: OP_NAMES.join(" | "),
421
455
  },
422
456
  };
423
- for (const param of PARAMS)
424
- properties[param.name] = propertyFor(param);
457
+ for (const param of PARAMS) {
458
+ if (!param.cliOnly)
459
+ properties[param.name] = propertyFor(param);
460
+ }
425
461
  return {
426
462
  type: "object",
427
463
  additionalProperties: false,
@@ -447,7 +483,7 @@ export function cliUsage() {
447
483
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
448
484
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
449
485
  }
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", "");
486
+ 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 (+ ZSWARM_SERVE_TOKEN). Serve binds loopback only and always requires a token.", "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
487
  return lines.join("\n");
452
488
  }
453
489
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
@@ -496,6 +532,9 @@ export function parseCliArgv(argv) {
496
532
  out[param.name] = true;
497
533
  continue;
498
534
  }
535
+ if ((param.name === "body" || param.name === "bodyFile") && Object.hasOwn(out, param.name)) {
536
+ throw new ZellijError("usage", "provide exactly one body source");
537
+ }
499
538
  const value = rest[++i];
500
539
  if (value === undefined) {
501
540
  throw new ZellijError("usage", `${token} needs a value`);
@@ -516,7 +555,7 @@ export function parseCliArgv(argv) {
516
555
  out.to = token;
517
556
  continue;
518
557
  }
519
- if (!out.body && op === "send") {
558
+ if (!Object.hasOwn(out, "body") && op === "send") {
520
559
  out.body = token;
521
560
  continue;
522
561
  }
@@ -524,5 +563,8 @@ export function parseCliArgv(argv) {
524
563
  }
525
564
  for (const [name, values] of repeated)
526
565
  out[name] = values;
566
+ if (out.bodyFile !== undefined && (op !== "send" || out.body !== undefined || out.text !== undefined)) {
567
+ throw new ZellijError("usage", "--body-file requires send and cannot be combined with another body source");
568
+ }
527
569
  return out;
528
570
  }
package/dist/state.d.ts CHANGED
@@ -19,9 +19,10 @@ export type SignalChannel = {
19
19
  last: string | null;
20
20
  };
21
21
  /**
22
- * Written once by `bus --install`, after the plugin's permission prompt has
23
- * been answered. Its presence is what lets later runs try the fast path without
24
- * every cold `zswarm status` paying for a pipe that was never going to answer.
22
+ * Written by `bus --install` per Zellij session, after the plugin's permission
23
+ * prompt has been answered. Its presence is what lets later runs try the fast
24
+ * path without every cold `zswarm status` paying for a pipe that was never
25
+ * going to answer.
25
26
  */
26
27
  export type BusMarkerRecord = {
27
28
  plugin: string;
@@ -44,9 +45,9 @@ export declare function createStateStore(options?: StateStoreOptions): {
44
45
  readCursor: (key: string) => string | null;
45
46
  writeCursor: (key: string, text: string) => void;
46
47
  clearCursor: (key: string) => void;
47
- readBus: () => BusMarkerRecord | null;
48
- writeBus: (marker: BusMarkerRecord) => void;
49
- clearBus: () => void;
48
+ readBus: (session: string) => BusMarkerRecord | null;
49
+ writeBus: (session: string, marker: BusMarkerRecord) => void;
50
+ clearBus: (session?: string) => void;
50
51
  reset: () => void;
51
52
  };
52
53
  export type StateStore = ReturnType<typeof createStateStore>;
package/dist/state.js CHANGED
@@ -10,6 +10,8 @@ const BUS_FILE = "bus.json";
10
10
  /** Keeps the log bounded without needing a rotation daemon. */
11
11
  const LOG_TAIL_BYTES = 512 * 1024;
12
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;
13
15
  function sleepSync(ms) {
14
16
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
15
17
  }
@@ -102,16 +104,24 @@ export function createStateStore(options = {}) {
102
104
  }
103
105
  }
104
106
  /**
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.
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.
108
113
  * Owner is read once: a second read can see a pid that appeared after an
109
114
  * empty snapshot and would steal a live lock.
110
115
  */
111
116
  function lockIsStale(lockPath) {
112
117
  const owner = readLockOwner(lockPath);
113
- if (owner)
114
- return !pidAlive(owner.pid);
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
+ }
115
125
  try {
116
126
  return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
117
127
  }
@@ -162,7 +172,7 @@ export function createStateStore(options = {}) {
162
172
  const code = err.code;
163
173
  if (!lockBusy(code))
164
174
  throw err;
165
- if (code === "EEXIST" && lockIsStale(lockPath)) {
175
+ if (lockIsStale(lockPath)) {
166
176
  unlinkLock(lockPath);
167
177
  continue;
168
178
  }
@@ -222,22 +232,72 @@ export function createStateStore(options = {}) {
222
232
  writeJson(CURSORS_FILE, all);
223
233
  });
224
234
  }
225
- function readBus() {
226
- const value = readJson(BUS_FILE, null);
227
- 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)
240
+ return null;
241
+ if (typeof rec.configKey !== "string" || !rec.configKey)
228
242
  return null;
229
- return value;
243
+ return {
244
+ plugin: rec.plugin,
245
+ configKey: rec.configKey,
246
+ installedAt: typeof rec.installedAt === "number" ? rec.installedAt : 0,
247
+ };
230
248
  }
231
- function writeBus(marker) {
232
- 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) };
233
267
  }
234
- function clearBus() {
235
- try {
236
- 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;
237
288
  }
238
- catch {
239
- // 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;
240
299
  }
300
+ writeJson(BUS_FILE, { sessions });
241
301
  }
242
302
  /** Test helper: drop everything this store wrote. */
243
303
  function reset() {
@@ -1,6 +1,7 @@
1
1
  export type PaneDirection = "right" | "left" | "up" | "down";
2
2
  export type NewPaneInput = {
3
3
  session: string;
4
+ timeoutMs?: number;
4
5
  command?: string[];
5
6
  cwd?: string | null;
6
7
  name?: string | null;
@@ -13,6 +14,7 @@ export type NewPaneInput = {
13
14
  };
14
15
  export type NewTabInput = {
15
16
  session: string;
17
+ timeoutMs?: number;
16
18
  command?: string[];
17
19
  cwd?: string | null;
18
20
  name?: string | null;
@@ -47,6 +49,8 @@ export type WaitRequest = {
47
49
  idleMs?: number;
48
50
  timeoutMs?: number;
49
51
  pollMs?: number;
52
+ /** Read scrollback above the viewport, matching dump --full. */
53
+ full?: boolean;
50
54
  };
51
55
  /**
52
56
  * `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. */
@@ -6,6 +6,40 @@ export declare const DEFAULT_TIMEOUT_MS = 15000;
6
6
  export { NOT_FOUND_EXIT };
7
7
  /** Expand a leading `~/` or `~\` using USERPROFILE/HOME. */
8
8
  export declare function expandHomePath(input: string, env?: NodeJS.ProcessEnv): string;
9
+ /**
10
+ * True when a path (or basename) is the zswarm CLI rather than Zellij.
11
+ * Setting ZSWARM_BIN to zswarm makes `list-sessions --short` fail with
12
+ * "unknown arg: --short" because zswarm re-parses argv as its own CLI.
13
+ */
14
+ export declare function looksLikeZswarmBinary(path: string): boolean;
15
+ export declare function assertZellijBinaryPath(path: string): void;
16
+ /**
17
+ * ZSWARM_SSH is a destination, not a full ssh argv.
18
+ * Accept `user@host` or an SSH config alias; put flags in ZSWARM_SSH_OPTS.
19
+ */
20
+ export declare function validateSshDestination(raw: string): string;
21
+ export declare function validateSshMode(raw: string): "ssh" | "interactive";
22
+ /** True when `--version` output is positively Zellij. */
23
+ export declare function isZellijVersionOutput(stdout: string, stderr?: string): boolean;
24
+ /**
25
+ * Cache key covering the resolved binary and SSH routing that can change the
26
+ * actual remote executable (opts, mode, remote bin).
27
+ */
28
+ export declare function identityCacheKey(zellijPath: string, ssh?: {
29
+ host: string;
30
+ options: string[];
31
+ mode?: string;
32
+ remoteBin?: string;
33
+ } | null): string;
34
+ /**
35
+ * Confirm the resolved binary is Zellij. Only verified identities are cached.
36
+ * Transport timeouts leave the cache empty so a later call can retry.
37
+ * Returns false when verification is unresolved; only true is cached.
38
+ */
39
+ export declare function ensureZellijIdentity(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
40
+ export declare function ensureZellijCapabilities(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
41
+ /** Test helper: drop cached identity/capability probes. */
42
+ export declare function resetZellijIdentityCache(): void;
9
43
  export declare function resolveZellijBinary(env?: NodeJS.ProcessEnv): string;
10
44
  export declare function sanitizeZellijEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
45
  /**
@@ -1,6 +1,7 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { delimiter, join } from "node:path";
3
+ import { basename, delimiter, join } from "node:path";
4
+ import { ZellijError } from "../errors.js";
4
5
  import { createExec, createSshExec, NOT_FOUND_EXIT, } from "../exec.js";
5
6
  import { applyIpcTmpEnv } from "./ipc.js";
6
7
  export { createSshExec };
@@ -16,15 +17,174 @@ export function expandHomePath(input, env = process.env) {
16
17
  return trimmed;
17
18
  return join(home, trimmed.slice(2));
18
19
  }
20
+ /**
21
+ * True when a path (or basename) is the zswarm CLI rather than Zellij.
22
+ * Setting ZSWARM_BIN to zswarm makes `list-sessions --short` fail with
23
+ * "unknown arg: --short" because zswarm re-parses argv as its own CLI.
24
+ */
25
+ export function looksLikeZswarmBinary(path) {
26
+ const trimmed = path.trim();
27
+ const base = basename(trimmed).toLowerCase();
28
+ // Windows paths on a Linux host still use backslashes in env values.
29
+ const winBase = trimmed.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? base;
30
+ const name = winBase || base;
31
+ if (/^zswarm(\.(exe|cmd|js|mjs|cjs))?$/.test(name))
32
+ return true;
33
+ // Native binaries are never the Node CLI wrapper.
34
+ if (/\.(exe|dll|so|dylib)$/i.test(name))
35
+ return false;
36
+ if (name === "zellij")
37
+ return false;
38
+ // Shebang wrappers may keep another basename; sniff a short readable prefix.
39
+ try {
40
+ if (!existsSync(trimmed))
41
+ return false;
42
+ const head = readFileSync(trimmed, { encoding: "utf8" }).slice(0, 400);
43
+ if (head.includes("\0"))
44
+ return false;
45
+ return /@zswarm\/cli/.test(head) || /usage:\s*zswarm/i.test(head);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ export function assertZellijBinaryPath(path) {
52
+ if (!looksLikeZswarmBinary(path))
53
+ return;
54
+ throw new ZellijError("zellij_wrong_bin", `ZSWARM_BIN/ZSWARM_PATH points at zswarm (${path}), not Zellij. Set it to the zellij binary (e.g. ~/.local/bin/zellij)`);
55
+ }
56
+ /**
57
+ * ZSWARM_SSH is a destination, not a full ssh argv.
58
+ * Accept `user@host` or an SSH config alias; put flags in ZSWARM_SSH_OPTS.
59
+ */
60
+ export function validateSshDestination(raw) {
61
+ const host = raw.trim();
62
+ if (!host) {
63
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH is empty");
64
+ }
65
+ if (/^ssh(\s|$)/i.test(host)) {
66
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH should be user@host or an SSH alias, not a full ssh command. Put flags in ZSWARM_SSH_OPTS. Got: ${JSON.stringify(host)}`);
67
+ }
68
+ // Leading dashes are SSH options (`-V`, `-F…`, `-o…`), not destinations.
69
+ if (host.startsWith("-")) {
70
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH looks like an SSH option (${JSON.stringify(host)}); put flags in ZSWARM_SSH_OPTS and set ZSWARM_SSH to user@host or an alias`);
71
+ }
72
+ if (/\s/.test(host)) {
73
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH must be a single destination (user@host or alias); put options in ZSWARM_SSH_OPTS. Got: ${JSON.stringify(host)}`);
74
+ }
75
+ if (/[;|&$<>()]/.test(host)) {
76
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH contains shell metacharacters; expected user@host or an SSH alias. Got: ${JSON.stringify(host)}`);
77
+ }
78
+ return host;
79
+ }
80
+ export function validateSshMode(raw) {
81
+ const mode = raw.trim().toLowerCase();
82
+ if (!mode || mode === "ssh")
83
+ return "ssh";
84
+ if (mode === "interactive")
85
+ return "interactive";
86
+ throw new ZellijError("bad_ssh_mode", `ZSWARM_SSH_MODE must be "interactive" or "ssh" (or unset); got ${JSON.stringify(raw.trim())}`);
87
+ }
88
+ /** Cache of verified `zellij --version` probes keyed by target identity. */
89
+ const identityCache = new Set();
90
+ /** True when `--version` output is positively Zellij. */
91
+ export function isZellijVersionOutput(stdout, stderr = "") {
92
+ const text = `${stdout}\n${stderr}`;
93
+ return /\bzellij\s+\d+\.\d+/i.test(text) || /^\s*zellij\b/im.test(stdout);
94
+ }
95
+ /**
96
+ * Cache key covering the resolved binary and SSH routing that can change the
97
+ * actual remote executable (opts, mode, remote bin).
98
+ */
99
+ export function identityCacheKey(zellijPath, ssh) {
100
+ if (!ssh)
101
+ return zellijPath;
102
+ return [
103
+ zellijPath,
104
+ ssh.host,
105
+ ssh.remoteBin ?? "",
106
+ ssh.mode ?? "ssh",
107
+ ssh.options.join("\0"),
108
+ ].join("|");
109
+ }
110
+ /**
111
+ * Confirm the resolved binary is Zellij. Only verified identities are cached.
112
+ * Transport timeouts leave the cache empty so a later call can retry.
113
+ * Returns false when verification is unresolved; only true is cached.
114
+ */
115
+ export async function ensureZellijIdentity(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
116
+ if (identityCache.has(cacheKey))
117
+ return true;
118
+ assertZellijBinaryPath(zellijPath);
119
+ const result = await exec(["--version"], {
120
+ timeoutMs: Math.min(timeoutMs, 5_000),
121
+ });
122
+ const text = `${result.stdout}\n${result.stderr}`;
123
+ if (/usage:\s*zswarm/i.test(text) || /unknown arg:/i.test(text)) {
124
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath}). Set ZSWARM_BIN to the zellij executable`);
125
+ }
126
+ if (result.code === NOT_FOUND_EXIT) {
127
+ throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
128
+ }
129
+ if (result.code === 0) {
130
+ if (isZellijVersionOutput(result.stdout, result.stderr)) {
131
+ identityCache.add(cacheKey);
132
+ return true;
133
+ }
134
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is not Zellij (${zellijPath}); --version returned: ${result.stdout.trim() || result.stderr.trim() || "empty"}`);
135
+ }
136
+ // Timeout / transport failure: leave unresolved so a later call retries.
137
+ return false;
138
+ }
139
+ /**
140
+ * Probe that the target understands the session-list flags we rely on.
141
+ * Cached with the same key as identity once verified.
142
+ * Returns false when the transport cannot verify support.
143
+ */
144
+ const capabilityCache = new Set();
145
+ export async function ensureZellijCapabilities(exec, zellijPath, timeoutMs = 3_000, cacheKey = zellijPath) {
146
+ if (capabilityCache.has(cacheKey))
147
+ return true;
148
+ const result = await exec(["list-sessions", "--help"], {
149
+ timeoutMs: Math.min(timeoutMs, 5_000),
150
+ });
151
+ const text = `${result.stdout}\n${result.stderr}`;
152
+ if (/usage:\s*zswarm/i.test(text)) {
153
+ throw new ZellijError("zellij_wrong_bin", `resolved binary is zswarm, not Zellij (${zellijPath})`);
154
+ }
155
+ if (result.code === NOT_FOUND_EXIT) {
156
+ throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath})`);
157
+ }
158
+ // Require the flags this client always passes.
159
+ if (result.code === 0 &&
160
+ /--no-formatting/i.test(text) &&
161
+ /list-sessions/i.test(text)) {
162
+ capabilityCache.add(cacheKey);
163
+ return true;
164
+ }
165
+ if (result.code === 0) {
166
+ throw new ZellijError("zellij_incompatible", `Zellij at ${zellijPath} does not advertise list-sessions --no-formatting; upgrade Zellij (≥ 0.42) or zswarm`);
167
+ }
168
+ // Soft: leave unresolved on transport failure.
169
+ return false;
170
+ }
171
+ /** Test helper: drop cached identity/capability probes. */
172
+ export function resetZellijIdentityCache() {
173
+ identityCache.clear();
174
+ capabilityCache.clear();
175
+ }
19
176
  export function resolveZellijBinary(env = process.env) {
20
177
  const fromEnv = expandHomePath((env.ZSWARM_BIN ?? env.ZSWARM_PATH ?? env.ZELLIJ_BIN ?? "")
21
178
  .trim()
22
179
  .replace(/^['"]|['"]$/g, ""), env);
23
180
  if (fromEnv && existsSync(fromEnv)) {
181
+ assertZellijBinaryPath(fromEnv);
24
182
  if (/\.cmd$/i.test(fromEnv)) {
25
183
  const exe = fromEnv.replace(/\.cmd$/i, ".exe");
26
- if (existsSync(exe))
184
+ if (existsSync(exe)) {
185
+ assertZellijBinaryPath(exe);
27
186
  return exe;
187
+ }
28
188
  const wingetExe = join(env.LOCALAPPDATA ||
29
189
  join(env.USERPROFILE || env.HOME || homedir(), "AppData", "Local"), "Zellij", "zellij.exe");
30
190
  if (existsSync(wingetExe))
@@ -112,17 +272,18 @@ export function parseSshOpts(raw) {
112
272
  return out;
113
273
  }
114
274
  export function resolveSshTarget(env = process.env) {
115
- const host = env.ZSWARM_SSH?.trim();
116
- if (!host)
275
+ const raw = env.ZSWARM_SSH?.trim();
276
+ if (!raw)
117
277
  return null;
278
+ const host = validateSshDestination(raw);
118
279
  const options = parseSshOpts(env.ZSWARM_SSH_OPTS ?? "");
119
280
  if (!options.some((o) => o.startsWith("BatchMode"))) {
120
281
  options.unshift("-o", "BatchMode=yes");
121
282
  }
122
283
  const shellRaw = (env.ZSWARM_REMOTE_SHELL ?? "").trim().toLowerCase();
123
- const modeRaw = (env.ZSWARM_SSH_MODE ?? "").trim().toLowerCase();
284
+ const mode = validateSshMode(env.ZSWARM_SSH_MODE ?? "");
124
285
  const tmpRaw = env.ZSWARM_TMP?.trim();
125
- const interactive = modeRaw === "interactive";
286
+ const interactive = mode === "interactive";
126
287
  return {
127
288
  ssh: env.ZSWARM_SSH_BIN?.trim() || "ssh",
128
289
  host,
@@ -130,7 +291,7 @@ export function resolveSshTarget(env = process.env) {
130
291
  options,
131
292
  // Interactive tasks do not inherit the desktop TEMP; discover it unless set.
132
293
  tmp: tmpRaw || (interactive ? "auto" : undefined),
133
- mode: interactive ? "interactive" : "ssh",
294
+ mode,
134
295
  remoteShell: shellRaw === "cmd" || shellRaw === "sh" ? shellRaw : undefined,
135
296
  };
136
297
  }
@@ -23,6 +23,8 @@ export type BusSnapshot = {
23
23
  paneUpdates: number;
24
24
  tabUpdates: number;
25
25
  tabs: string[];
26
+ /** Stable IDs by tab position; absent on older running plugin instances. */
27
+ tabIds?: (number | null)[];
26
28
  panes: BusPane[];
27
29
  };
28
30
  export type BusMarker = {
@@ -67,6 +69,15 @@ export declare function resolveBusPlugin(env?: NodeJS.ProcessEnv): string | null
67
69
  export declare function busPluginUrl(pluginPath: string): string;
68
70
  /** zswarm-bus → zswarm-bus-2 → zswarm-bus-3: a fresh pipe destination each time. */
69
71
  export declare function nextConfigKey(key: string): string;
72
+ /**
73
+ * Plugin panes belonging to this bus — never tab-bar / status-bar / room.
74
+ * Titles are the `file:` URL (or the wasm basename) Zellij shows for the pane.
75
+ */
76
+ export declare function isBusPluginPane(pane: {
77
+ isPlugin?: boolean;
78
+ title?: string;
79
+ command?: string | null;
80
+ }, pluginPath?: string | null): boolean;
70
81
  /**
71
82
  * Pull the plugin's answer out of the pipe. Zellij interleaves its own notices
72
83
  * ("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;
@@ -166,6 +186,7 @@ function toSnapshot(value) {
166
186
  tabs: Array.isArray(value.tabs)
167
187
  ? value.tabs.filter((t) => typeof t === "string")
168
188
  : [],
189
+ ...(Array.isArray(value.tabIds) ? { tabIds: value.tabIds.map((id) => typeof id === "number" && Number.isInteger(id) && id >= 0 ? id : null) } : {}),
169
190
  panes,
170
191
  };
171
192
  }
@@ -270,7 +291,7 @@ export function busToPanes(snapshot) {
270
291
  command: pane.command,
271
292
  cwd: null,
272
293
  tabName: snapshot.tabs[pane.tab] ?? null,
273
- tabId: pane.tab,
294
+ tabId: snapshot.tabIds?.[pane.tab] ?? null,
274
295
  focused: pane.focused,
275
296
  exited: pane.exited,
276
297
  floating: false,