@zswarm/core 0.1.5 → 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 +12 -1
- package/dist/exec.js +89 -12
- package/dist/harness.js +12 -7
- package/dist/index.d.ts +7 -6
- package/dist/index.js +4 -4
- package/dist/ops/bus.d.ts +6 -3
- package/dist/ops/bus.js +55 -22
- package/dist/ops/dispatch.d.ts +7 -1
- package/dist/ops/dispatch.js +193 -65
- 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/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 +5 -1
- package/dist/ops/util.d.ts +3 -0
- package/dist/ops/util.js +2 -1
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +45 -6
- package/dist/zellij/args.d.ts +2 -0
- package/dist/zellij/binary.d.ts +34 -0
- package/dist/zellij/binary.js +169 -8
- package/dist/zellij/bus.d.ts +2 -0
- package/dist/zellij/bus.js +2 -1
- package/dist/zellij/client.d.ts +22 -5
- package/dist/zellij/client.js +97 -32
- 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/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 = {
|
package/dist/zellij/bus.js
CHANGED
|
@@ -186,6 +186,7 @@ function toSnapshot(value) {
|
|
|
186
186
|
tabs: Array.isArray(value.tabs)
|
|
187
187
|
? value.tabs.filter((t) => typeof t === "string")
|
|
188
188
|
: [],
|
|
189
|
+
...(Array.isArray(value.tabIds) ? { tabIds: value.tabIds.map((id) => typeof id === "number" && Number.isInteger(id) && id >= 0 ? id : null) } : {}),
|
|
189
190
|
panes,
|
|
190
191
|
};
|
|
191
192
|
}
|
|
@@ -290,7 +291,7 @@ export function busToPanes(snapshot) {
|
|
|
290
291
|
command: pane.command,
|
|
291
292
|
cwd: null,
|
|
292
293
|
tabName: snapshot.tabs[pane.tab] ?? null,
|
|
293
|
-
tabId: pane.tab,
|
|
294
|
+
tabId: snapshot.tabIds?.[pane.tab] ?? null,
|
|
294
295
|
focused: pane.focused,
|
|
295
296
|
exited: pane.exited,
|
|
296
297
|
floating: false,
|
package/dist/zellij/client.d.ts
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
import { buildClosePaneArgs, buildDumpArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, type WaitRequest, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PipeInput } from "./args.js";
|
|
2
2
|
import { resolveTab, type ZellijTab } from "./tabs.js";
|
|
3
|
+
import { type IpcDiscoveryState } from "../exec.js";
|
|
3
4
|
import { type ZellijExecFn } from "./binary.js";
|
|
4
5
|
import { normalizePaneId, resolvePane, type ZellijPane } from "./panes.js";
|
|
5
|
-
import { type ZellijSessionResolve } from "./session.js";
|
|
6
|
+
import { type ZellijSession, type ZellijSessionResolve } from "./session.js";
|
|
6
7
|
export type ZellijClientOptions = {
|
|
7
8
|
exec?: ZellijExecFn;
|
|
8
9
|
zellijPath?: string;
|
|
9
10
|
timeoutMs?: number;
|
|
10
11
|
env?: NodeJS.ProcessEnv;
|
|
11
12
|
signal?: AbortSignal;
|
|
13
|
+
/** Skip the one-time `zellij --version` identity probe (tests). */
|
|
14
|
+
skipIdentityProbe?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type ZellijTransport = {
|
|
17
|
+
kind: "local" | "ssh";
|
|
18
|
+
mode: "local" | "ssh" | "interactive";
|
|
19
|
+
host?: string;
|
|
20
|
+
remoteBin?: string;
|
|
21
|
+
/** Configured or resolved IPC temp (`auto` until discovery succeeds). */
|
|
22
|
+
tmp?: string;
|
|
23
|
+
/** Outcome of `ZSWARM_TMP=auto` discovery when SSH routing is active. */
|
|
24
|
+
ipc?: IpcDiscoveryState;
|
|
12
25
|
};
|
|
13
26
|
/** Thin, stateless wrapper over the `zellij` binary. */
|
|
14
27
|
export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
15
28
|
zellijPath: string;
|
|
16
29
|
selfPaneId: string | null;
|
|
30
|
+
readonly transport: ZellijTransport;
|
|
17
31
|
/** A `file:` plugin url only names a path on the machine running Zellij. */
|
|
18
32
|
remote: boolean;
|
|
19
|
-
listSessions: () => Promise<
|
|
20
|
-
resolveSession: (explicit?: string | null) => Promise<ZellijSessionResolve>;
|
|
21
|
-
listPanes: (session: string) => Promise<ZellijPane[]>;
|
|
33
|
+
listSessions: (callTimeoutMs?: number) => Promise<ZellijSession[]>;
|
|
34
|
+
resolveSession: (explicit?: string | null, callTimeoutMs?: number) => Promise<ZellijSessionResolve>;
|
|
35
|
+
listPanes: (session: string, callTimeoutMs?: number) => Promise<ZellijPane[]>;
|
|
22
36
|
resolvePane: typeof resolvePane;
|
|
23
37
|
injectPane: (input: {
|
|
24
38
|
session: string;
|
|
@@ -32,6 +46,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
|
32
46
|
session: string;
|
|
33
47
|
paneId: string;
|
|
34
48
|
full?: boolean;
|
|
49
|
+
timeoutMs?: number;
|
|
35
50
|
}) => Promise<{
|
|
36
51
|
paneId: string;
|
|
37
52
|
session: string;
|
|
@@ -75,6 +90,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
|
75
90
|
session: string;
|
|
76
91
|
paneId: string;
|
|
77
92
|
name: string;
|
|
93
|
+
timeoutMs?: number;
|
|
78
94
|
}) => Promise<{
|
|
79
95
|
paneId: string;
|
|
80
96
|
session: string;
|
|
@@ -96,7 +112,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
|
96
112
|
paneId: string;
|
|
97
113
|
session: string;
|
|
98
114
|
}>;
|
|
99
|
-
listTabs: (session: string) => Promise<ZellijTab[]>;
|
|
115
|
+
listTabs: (session: string, callTimeoutMs?: number) => Promise<ZellijTab[]>;
|
|
100
116
|
dumpLayout: (session: string) => Promise<string>;
|
|
101
117
|
stackPanes: (input: {
|
|
102
118
|
session: string;
|
|
@@ -138,6 +154,7 @@ export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
|
138
154
|
url: string;
|
|
139
155
|
configKey: string;
|
|
140
156
|
panes: string[];
|
|
157
|
+
timeoutMs?: number;
|
|
141
158
|
}) => Promise<{
|
|
142
159
|
code: number;
|
|
143
160
|
stdout: string;
|
package/dist/zellij/client.js
CHANGED
|
@@ -3,55 +3,115 @@ import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneA
|
|
|
3
3
|
import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
|
|
4
4
|
import { parseTabList, resolveTab } from "./tabs.js";
|
|
5
5
|
import { createSshExec } from "../exec.js";
|
|
6
|
-
import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
|
|
6
|
+
import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, ensureZellijCapabilities, ensureZellijIdentity, identityCacheKey, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
|
|
7
7
|
import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
|
|
8
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;
|
|
12
|
-
// A remote crew never resolves a local binary.
|
|
12
|
+
// A remote crew never resolves a local binary. An injected exec is a unit
|
|
13
|
+
// test (or in-process stand-in) — skip PATH/env resolution so a poisoned
|
|
14
|
+
// ZSWARM_BIN in the host environment cannot fail client construction.
|
|
13
15
|
const ssh = options.exec ? null : resolveSshTarget(env);
|
|
14
16
|
const zellijPath = options.zellijPath ??
|
|
15
|
-
(
|
|
17
|
+
(options.exec
|
|
18
|
+
? "zellij"
|
|
19
|
+
: ssh
|
|
20
|
+
? `ssh://${ssh.host}/${ssh.remoteBin}`
|
|
21
|
+
: resolveZellijBinary(env));
|
|
22
|
+
const sshExec = options.exec || !ssh
|
|
23
|
+
? null
|
|
24
|
+
: createSshExec(ssh, sanitizeZellijEnv(env));
|
|
16
25
|
const rawExec = options.exec ??
|
|
17
|
-
(
|
|
18
|
-
? createSshExec(ssh, sanitizeZellijEnv(env))
|
|
19
|
-
: defaultExec(zellijPath, env));
|
|
26
|
+
(sshExec ? sshExec : defaultExec(zellijPath, env));
|
|
20
27
|
const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
|
|
21
28
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
22
29
|
const selfPaneId = resolveSelfPaneId(env);
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
const probeKey = identityCacheKey(zellijPath, ssh);
|
|
31
|
+
function readTransport() {
|
|
32
|
+
if (!ssh)
|
|
33
|
+
return { kind: "local", mode: "local" };
|
|
34
|
+
const ipc = sshExec?.ipcState;
|
|
35
|
+
return {
|
|
36
|
+
kind: "ssh",
|
|
37
|
+
mode: ssh.mode ?? "ssh",
|
|
38
|
+
host: ssh.host,
|
|
39
|
+
remoteBin: ssh.remoteBin,
|
|
40
|
+
tmp: ipc?.tmp ?? ssh.tmp,
|
|
41
|
+
ipc: ipc
|
|
42
|
+
? {
|
|
43
|
+
requested: ipc.requested,
|
|
44
|
+
status: ipc.status,
|
|
45
|
+
tmp: ipc.tmp,
|
|
46
|
+
socketDir: ipc.socketDir,
|
|
47
|
+
}
|
|
48
|
+
: undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function operationBudget(budget, label) {
|
|
52
|
+
const deadline = Date.now() + budget;
|
|
53
|
+
return () => {
|
|
54
|
+
if (options.signal?.aborted) {
|
|
55
|
+
throw new ZellijError("cancelled", "operation cancelled");
|
|
56
|
+
}
|
|
57
|
+
const left = deadline - Date.now();
|
|
58
|
+
if (left <= 0) {
|
|
59
|
+
throw new ZellijError("zellij_failed", `${label} timed out after ${budget}ms`);
|
|
60
|
+
}
|
|
61
|
+
return left;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function ensureIdentity(remaining) {
|
|
65
|
+
if (options.skipIdentityProbe || options.exec)
|
|
66
|
+
return;
|
|
67
|
+
// Only the helpers' positively verified results are cached. In-flight or
|
|
68
|
+
// unresolved probes must not tie this call to another operation's budget.
|
|
69
|
+
await ensureZellijIdentity(exec, zellijPath, remaining(), probeKey);
|
|
70
|
+
await ensureZellijCapabilities(exec, zellijPath, remaining(), probeKey);
|
|
71
|
+
}
|
|
72
|
+
async function run(args, label, callTimeoutMs = timeoutMs) {
|
|
73
|
+
const remaining = operationBudget(callTimeoutMs, label);
|
|
74
|
+
await ensureIdentity(remaining);
|
|
75
|
+
const result = await exec(args, { timeoutMs: remaining() });
|
|
25
76
|
if (result.code === NOT_FOUND_EXIT) {
|
|
26
77
|
throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
|
|
27
78
|
}
|
|
28
79
|
if (result.code !== 0) {
|
|
29
|
-
|
|
80
|
+
const detail = result.stderr.trim() || result.stdout.trim() || "no output";
|
|
81
|
+
if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
|
|
82
|
+
throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
|
|
83
|
+
}
|
|
84
|
+
throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${detail}`);
|
|
30
85
|
}
|
|
31
86
|
return result;
|
|
32
87
|
}
|
|
33
|
-
async function listSessions() {
|
|
34
|
-
const
|
|
35
|
-
|
|
88
|
+
async function listSessions(callTimeoutMs = timeoutMs) {
|
|
89
|
+
const remaining = operationBudget(callTimeoutMs, "zellij list-sessions");
|
|
90
|
+
await ensureIdentity(remaining);
|
|
91
|
+
// Keep annotations (EXITED / current); --short drops them.
|
|
92
|
+
const result = await exec(["list-sessions", "--no-formatting"], {
|
|
93
|
+
timeoutMs: remaining(),
|
|
36
94
|
});
|
|
37
95
|
if (result.code === NOT_FOUND_EXIT) {
|
|
38
96
|
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
97
|
}
|
|
40
98
|
if (result.code !== 0) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
99
|
+
const detail = result.stderr.trim() || result.stdout.trim() || "no output";
|
|
100
|
+
if (/usage:\s*zswarm/i.test(detail) || /unknown arg:/i.test(detail)) {
|
|
101
|
+
throw new ZellijError("zellij_wrong_bin", `resolved binary looks like zswarm, not Zellij (${zellijPath}): ${detail}`);
|
|
102
|
+
}
|
|
44
103
|
if (isZellijNoSessionsOutput(result.stdout, result.stderr))
|
|
45
104
|
return [];
|
|
46
|
-
throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${
|
|
105
|
+
throw new ZellijError("zellij_failed", `zellij list-sessions failed (exit ${result.code}): ${detail}`);
|
|
47
106
|
}
|
|
48
107
|
return parseSessionList(result.stdout);
|
|
49
108
|
}
|
|
50
|
-
async function resolveSession(explicit) {
|
|
51
|
-
return sessionFromEnv(env, explicit) ??
|
|
109
|
+
async function resolveSession(explicit, callTimeoutMs = timeoutMs) {
|
|
110
|
+
return (sessionFromEnv(env, explicit) ??
|
|
111
|
+
sessionFromList(await listSessions(callTimeoutMs)));
|
|
52
112
|
}
|
|
53
|
-
async function listPanes(session) {
|
|
54
|
-
const result = await run(buildListPanesArgs(session), "zellij action list-panes");
|
|
113
|
+
async function listPanes(session, callTimeoutMs = timeoutMs) {
|
|
114
|
+
const result = await run(buildListPanesArgs(session), "zellij action list-panes", callTimeoutMs);
|
|
55
115
|
return parsePaneList(result.stdout);
|
|
56
116
|
}
|
|
57
117
|
async function injectPane(input) {
|
|
@@ -65,7 +125,7 @@ export function createZellijClient(options = {}) {
|
|
|
65
125
|
}
|
|
66
126
|
async function dumpPane(input) {
|
|
67
127
|
const paneId = normalizePaneId(input.paneId);
|
|
68
|
-
const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen");
|
|
128
|
+
const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen", input.timeoutMs ?? timeoutMs);
|
|
69
129
|
return { paneId, session: input.session, text: result.stdout };
|
|
70
130
|
}
|
|
71
131
|
async function sendKeys(input) {
|
|
@@ -89,7 +149,7 @@ export function createZellijClient(options = {}) {
|
|
|
89
149
|
if (!input.name.trim()) {
|
|
90
150
|
throw new ZellijError("missing_name", "name required");
|
|
91
151
|
}
|
|
92
|
-
await run(buildRenamePaneArgs(input.session, paneId, input.name), "zellij action rename-pane");
|
|
152
|
+
await run(buildRenamePaneArgs(input.session, paneId, input.name), "zellij action rename-pane", input.timeoutMs ?? timeoutMs);
|
|
93
153
|
return { paneId, session: input.session, name: input.name };
|
|
94
154
|
}
|
|
95
155
|
async function renameTab(input) {
|
|
@@ -104,8 +164,8 @@ export function createZellijClient(options = {}) {
|
|
|
104
164
|
await run(buildFocusPaneArgs(input.session, paneId), "zellij action focus-pane-id");
|
|
105
165
|
return { paneId, session: input.session };
|
|
106
166
|
}
|
|
107
|
-
async function listTabs(session) {
|
|
108
|
-
const result = await run(buildListTabsArgs(session), "zellij action list-tabs");
|
|
167
|
+
async function listTabs(session, callTimeoutMs = timeoutMs) {
|
|
168
|
+
const result = await run(buildListTabsArgs(session), "zellij action list-tabs", callTimeoutMs);
|
|
109
169
|
return parseTabList(result.stdout);
|
|
110
170
|
}
|
|
111
171
|
async function dumpLayout(session) {
|
|
@@ -129,11 +189,12 @@ export function createZellijClient(options = {}) {
|
|
|
129
189
|
}
|
|
130
190
|
/** `new-pane` prints the created pane id; `new-tab` prints a tab id instead. */
|
|
131
191
|
function parseCreatedPaneId(stdout) {
|
|
132
|
-
const
|
|
133
|
-
|
|
192
|
+
const ids = new Set(stdout.split(/\r?\n/).map((line) => line.trim().toLowerCase())
|
|
193
|
+
.filter((line) => /^(terminal|plugin)_\d+$/.test(line)));
|
|
194
|
+
return ids.size === 1 ? [...ids][0] : null;
|
|
134
195
|
}
|
|
135
196
|
async function newPane(input) {
|
|
136
|
-
const result = await run(buildNewPaneArgs(input), "zellij action new-pane");
|
|
197
|
+
const result = await run(buildNewPaneArgs(input), "zellij action new-pane", input.timeoutMs ?? timeoutMs);
|
|
137
198
|
return {
|
|
138
199
|
session: input.session,
|
|
139
200
|
paneId: parseCreatedPaneId(result.stdout),
|
|
@@ -141,11 +202,12 @@ export function createZellijClient(options = {}) {
|
|
|
141
202
|
};
|
|
142
203
|
}
|
|
143
204
|
async function newTab(input) {
|
|
144
|
-
const result = await run(buildNewTabArgs(input), "zellij action new-tab");
|
|
145
|
-
const
|
|
205
|
+
const result = await run(buildNewTabArgs(input), "zellij action new-tab", input.timeoutMs ?? timeoutMs);
|
|
206
|
+
const ids = new Set(result.stdout.split(/\r?\n/).map((line) => line.trim())
|
|
207
|
+
.filter((line) => /^\d+$/.test(line)).map(Number).filter(Number.isSafeInteger));
|
|
146
208
|
return {
|
|
147
209
|
session: input.session,
|
|
148
|
-
tabId:
|
|
210
|
+
tabId: ids.size === 1 ? [...ids][0] : null,
|
|
149
211
|
stdout: result.stdout.trim(),
|
|
150
212
|
};
|
|
151
213
|
}
|
|
@@ -201,7 +263,7 @@ export function createZellijClient(options = {}) {
|
|
|
201
263
|
configKey: input.configKey,
|
|
202
264
|
payload: changedPayload(input.panes),
|
|
203
265
|
}), {
|
|
204
|
-
timeoutMs: DEFAULT_BUS_TIMEOUT_MS,
|
|
266
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_BUS_TIMEOUT_MS,
|
|
205
267
|
until: (stdout) => parseChangedReply(stdout) !== null,
|
|
206
268
|
});
|
|
207
269
|
}
|
|
@@ -215,11 +277,14 @@ export function createZellijClient(options = {}) {
|
|
|
215
277
|
/** Visible prefix so peer CLIs can tell zSwarm injects from human prompts. */
|
|
216
278
|
function formatPeerMessage(from, body) {
|
|
217
279
|
const sender = from.trim() || "swarm";
|
|
218
|
-
return `[zswarm from=${sender}]\n${body
|
|
280
|
+
return `[zswarm from=${sender}]\n${body}`;
|
|
219
281
|
}
|
|
220
282
|
return {
|
|
221
283
|
zellijPath,
|
|
222
284
|
selfPaneId,
|
|
285
|
+
get transport() {
|
|
286
|
+
return readTransport();
|
|
287
|
+
},
|
|
223
288
|
/** A `file:` plugin url only names a path on the machine running Zellij. */
|
|
224
289
|
remote: ssh !== null,
|
|
225
290
|
listSessions,
|
package/dist/zellij/panes.js
CHANGED
|
@@ -68,6 +68,9 @@ export function resolvePane(panes, to) {
|
|
|
68
68
|
const byTypedId = panes.find((p) => p.id === key.toLowerCase());
|
|
69
69
|
if (byTypedId)
|
|
70
70
|
return byTypedId;
|
|
71
|
+
if (/^(terminal|plugin)_\d+$/i.test(key)) {
|
|
72
|
+
throw new ZellijError("peer_not_found", `pane ${key} is not in the current listing`);
|
|
73
|
+
}
|
|
71
74
|
if (/^\d+$/.test(key)) {
|
|
72
75
|
const terminals = panes.filter((p) => !p.isPlugin && String(p.numericId) === key);
|
|
73
76
|
if (terminals.length === 1)
|
package/dist/zellij/session.d.ts
CHANGED
|
@@ -2,15 +2,31 @@ export type ZellijSessionResolve = {
|
|
|
2
2
|
session: string;
|
|
3
3
|
source: "arg" | "env_zswarm" | "env_zellij" | "sole_live";
|
|
4
4
|
};
|
|
5
|
+
/** One row from `zellij list-sessions --no-formatting` (not `--short`). */
|
|
6
|
+
export type ZellijSession = {
|
|
7
|
+
name: string;
|
|
8
|
+
/** True when Zellij marks the row EXITED (resurrectable, no live server). */
|
|
9
|
+
exited: boolean;
|
|
10
|
+
/** True when the listing marks this session as the caller's current one. */
|
|
11
|
+
current: boolean;
|
|
12
|
+
};
|
|
5
13
|
/**
|
|
6
14
|
* Pane hosting the caller, so writes can refuse to loop back into it.
|
|
7
15
|
* `ZSWARM_SELF_PANE` wins; Zellij exports `ZELLIJ_PANE_ID` inside a pane.
|
|
8
16
|
*/
|
|
9
17
|
export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
|
|
10
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Parse `list-sessions --no-formatting` output.
|
|
20
|
+
*
|
|
21
|
+
* Annotated form: `name [Created …] [(current)| (EXITED - …)]`.
|
|
22
|
+
* Bare names (from `--short` or test mocks) are treated as live.
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseSessionList(stdout: string): ZellijSession[];
|
|
25
|
+
/** Live session names only — used when auto-picking a sole session. */
|
|
26
|
+
export declare function liveSessionNames(sessions: ZellijSession[]): string[];
|
|
11
27
|
/** Zellij exits 1 and prints this when the machine simply has no sessions. */
|
|
12
28
|
export declare function isZellijNoSessionsOutput(stdout: string, stderr: string): boolean;
|
|
13
29
|
/** Session from an explicit argument or the environment, before asking Zellij. */
|
|
14
30
|
export declare function sessionFromEnv(env: NodeJS.ProcessEnv, explicit?: string | null): ZellijSessionResolve | null;
|
|
15
|
-
/** Pick a session when the environment gave no answer. */
|
|
16
|
-
export declare function sessionFromList(sessions:
|
|
31
|
+
/** Pick a session when the environment gave no answer. EXITED rows do not count. */
|
|
32
|
+
export declare function sessionFromList(sessions: ZellijSession[]): ZellijSessionResolve;
|
package/dist/zellij/session.js
CHANGED
|
@@ -13,11 +13,40 @@ export function resolveSelfPaneId(env = process.env) {
|
|
|
13
13
|
return raw.toLowerCase();
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse `list-sessions --no-formatting` output.
|
|
18
|
+
*
|
|
19
|
+
* Annotated form: `name [Created …] [(current)| (EXITED - …)]`.
|
|
20
|
+
* Bare names (from `--short` or test mocks) are treated as live.
|
|
21
|
+
*/
|
|
16
22
|
export function parseSessionList(stdout) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.
|
|
23
|
+
const sessions = [];
|
|
24
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
25
|
+
// Drop ANSI in case a caller forgot --no-formatting.
|
|
26
|
+
const line = raw.replace(/\x1B\[[0-9;]*m/g, "").trim();
|
|
27
|
+
if (!line)
|
|
28
|
+
continue;
|
|
29
|
+
const created = line.indexOf(" [Created ");
|
|
30
|
+
if (created >= 0) {
|
|
31
|
+
const name = line.slice(0, created).trim();
|
|
32
|
+
if (!name)
|
|
33
|
+
continue;
|
|
34
|
+
const suffix = line.slice(created);
|
|
35
|
+
sessions.push({
|
|
36
|
+
name,
|
|
37
|
+
exited: /\(EXITED\b/i.test(suffix),
|
|
38
|
+
current: /\(current\)/i.test(suffix),
|
|
39
|
+
});
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Bare name from --short or a mock.
|
|
43
|
+
sessions.push({ name: line, exited: false, current: false });
|
|
44
|
+
}
|
|
45
|
+
return sessions;
|
|
46
|
+
}
|
|
47
|
+
/** Live session names only — used when auto-picking a sole session. */
|
|
48
|
+
export function liveSessionNames(sessions) {
|
|
49
|
+
return sessions.filter((s) => !s.exited).map((s) => s.name);
|
|
21
50
|
}
|
|
22
51
|
/** Zellij exits 1 and prints this when the machine simply has no sessions. */
|
|
23
52
|
export function isZellijNoSessionsOutput(stdout, stderr) {
|
|
@@ -36,13 +65,14 @@ export function sessionFromEnv(env, explicit) {
|
|
|
36
65
|
return { session: zellijEnv, source: "env_zellij" };
|
|
37
66
|
return null;
|
|
38
67
|
}
|
|
39
|
-
/** Pick a session when the environment gave no answer. */
|
|
68
|
+
/** Pick a session when the environment gave no answer. EXITED rows do not count. */
|
|
40
69
|
export function sessionFromList(sessions) {
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
const live = liveSessionNames(sessions);
|
|
71
|
+
if (live.length === 1) {
|
|
72
|
+
return { session: live[0], source: "sole_live" };
|
|
43
73
|
}
|
|
44
|
-
if (
|
|
74
|
+
if (live.length === 0) {
|
|
45
75
|
throw new ZellijError("zellij_no_session", "no live Zellij sessions; start zellij or pass session=");
|
|
46
76
|
}
|
|
47
|
-
throw new ZellijError("zellij_session_ambiguous", `multiple Zellij sessions (${
|
|
77
|
+
throw new ZellijError("zellij_session_ambiguous", `multiple Zellij sessions (${live.join(", ")}); pass session=`);
|
|
48
78
|
}
|