@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.
- package/README.md +4 -1
- package/dist/exec.d.ts +14 -1
- package/dist/exec.js +107 -14
- package/dist/harness.js +12 -7
- package/dist/index.d.ts +11 -10
- package/dist/index.js +8 -8
- package/dist/ops/bus.d.ts +16 -10
- package/dist/ops/bus.js +164 -98
- package/dist/ops/delivery.d.ts +3 -3
- package/dist/ops/delivery.js +4 -7
- package/dist/ops/dispatch.d.ts +7 -1
- package/dist/ops/dispatch.js +208 -68
- package/dist/ops/guards.d.ts +2 -0
- package/dist/ops/guards.js +11 -1
- package/dist/ops/observation.d.ts +8 -0
- package/dist/ops/observation.js +26 -0
- package/dist/ops/routing.d.ts +13 -0
- package/dist/ops/routing.js +21 -0
- package/dist/ops/serve.d.ts +1 -0
- package/dist/ops/serve.js +7 -1
- package/dist/ops/spawn.d.ts +3 -3
- package/dist/ops/spawn.js +130 -64
- package/dist/ops/status.d.ts +30 -5
- package/dist/ops/status.js +231 -91
- package/dist/ops/types.d.ts +7 -1
- package/dist/ops/util.d.ts +4 -0
- package/dist/ops/util.js +7 -1
- package/dist/ops/wait.d.ts +8 -1
- package/dist/ops/wait.js +7 -3
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +50 -8
- package/dist/state.d.ts +7 -6
- package/dist/state.js +77 -17
- package/dist/zellij/args.d.ts +4 -0
- package/dist/zellij/args.js +1 -0
- package/dist/zellij/binary.d.ts +34 -0
- package/dist/zellij/binary.js +169 -8
- package/dist/zellij/bus.d.ts +11 -0
- package/dist/zellij/bus.js +22 -1
- package/dist/zellij/client.d.ts +23 -5
- package/dist/zellij/client.js +99 -33
- package/dist/zellij/panes.js +3 -0
- package/dist/zellij/session.d.ts +19 -3
- package/dist/zellij/session.js +39 -9
- 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:
|
|
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: "
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
23
|
-
* been answered. Its presence is what lets later runs try the fast
|
|
24
|
-
* every cold `zswarm status` paying for a pipe that was never
|
|
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.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
243
|
+
return {
|
|
244
|
+
plugin: rec.plugin,
|
|
245
|
+
configKey: rec.configKey,
|
|
246
|
+
installedAt: typeof rec.installedAt === "number" ? rec.installedAt : 0,
|
|
247
|
+
};
|
|
230
248
|
}
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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() {
|
package/dist/zellij/args.d.ts
CHANGED
|
@@ -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
|
package/dist/zellij/args.js
CHANGED
package/dist/zellij/binary.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/zellij/binary.js
CHANGED
|
@@ -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
|
|
116
|
-
if (!
|
|
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
|
|
284
|
+
const mode = validateSshMode(env.ZSWARM_SSH_MODE ?? "");
|
|
124
285
|
const tmpRaw = env.ZSWARM_TMP?.trim();
|
|
125
|
-
const 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
|
|
294
|
+
mode,
|
|
134
295
|
remoteShell: shellRaw === "cmd" || shellRaw === "sh" ? shellRaw : undefined,
|
|
135
296
|
};
|
|
136
297
|
}
|
package/dist/zellij/bus.d.ts
CHANGED
|
@@ -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
|
package/dist/zellij/bus.js
CHANGED
|
@@ -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,
|