@zswarm/core 0.1.0
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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +118 -0
- package/dist/git.d.ts +51 -0
- package/dist/git.js +190 -0
- package/dist/harness.d.ts +21 -0
- package/dist/harness.js +98 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/keys.d.ts +15 -0
- package/dist/keys.js +168 -0
- package/dist/ops/broadcast.d.ts +19 -0
- package/dist/ops/broadcast.js +113 -0
- package/dist/ops/bus.d.ts +59 -0
- package/dist/ops/bus.js +284 -0
- package/dist/ops/delivery.d.ts +43 -0
- package/dist/ops/delivery.js +191 -0
- package/dist/ops/dispatch.d.ts +4 -0
- package/dist/ops/dispatch.js +331 -0
- package/dist/ops/guards.d.ts +15 -0
- package/dist/ops/guards.js +36 -0
- package/dist/ops/log.d.ts +4 -0
- package/dist/ops/log.js +26 -0
- package/dist/ops/panes.d.ts +13 -0
- package/dist/ops/panes.js +111 -0
- package/dist/ops/review.d.ts +9 -0
- package/dist/ops/review.js +108 -0
- package/dist/ops/signals.d.ts +10 -0
- package/dist/ops/signals.js +86 -0
- package/dist/ops/spawn.d.ts +5 -0
- package/dist/ops/spawn.js +98 -0
- package/dist/ops/status.d.ts +36 -0
- package/dist/ops/status.js +192 -0
- package/dist/ops/tail.d.ts +19 -0
- package/dist/ops/tail.js +59 -0
- package/dist/ops/types.d.ts +26 -0
- package/dist/ops/types.js +1 -0
- package/dist/ops/util.d.ts +48 -0
- package/dist/ops/util.js +85 -0
- package/dist/ops/wait.d.ts +19 -0
- package/dist/ops/wait.js +138 -0
- package/dist/ops/worktree.d.ts +21 -0
- package/dist/ops/worktree.js +144 -0
- package/dist/policy.d.ts +15 -0
- package/dist/policy.js +75 -0
- package/dist/schema.d.ts +26 -0
- package/dist/schema.js +489 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +150 -0
- package/dist/zellij/args.d.ts +93 -0
- package/dist/zellij/args.js +261 -0
- package/dist/zellij/binary.d.ts +17 -0
- package/dist/zellij/binary.js +96 -0
- package/dist/zellij/bus.d.ts +91 -0
- package/dist/zellij/bus.js +279 -0
- package/dist/zellij/client.d.ts +162 -0
- package/dist/zellij/client.js +246 -0
- package/dist/zellij/panes.d.ts +21 -0
- package/dist/zellij/panes.js +110 -0
- package/dist/zellij/session.d.ts +14 -0
- package/dist/zellij/session.js +44 -0
- package/dist/zellij/tabs.d.ts +14 -0
- package/dist/zellij/tabs.js +56 -0
- package/package.json +34 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { expandHomePath } from "./binary.js";
|
|
6
|
+
/**
|
|
7
|
+
* The event-bus side of zswarm: Zellij pushes pane and tab state into a WASM
|
|
8
|
+
* plugin as it changes, and one CLI pipe reads it back out of memory. Parsing
|
|
9
|
+
* and path resolution live here; the process calls live in the client, and the
|
|
10
|
+
* fallback policy lives in `ops/bus.ts`.
|
|
11
|
+
*/
|
|
12
|
+
export const BUS_PIPE_NAME = "zswarm";
|
|
13
|
+
export const DEFAULT_BUS_KEY = "zswarm-bus";
|
|
14
|
+
/** A stalled instance answers nothing, so the pipe must not inherit the long timeout. */
|
|
15
|
+
export const DEFAULT_BUS_TIMEOUT_MS = 2_500;
|
|
16
|
+
/**
|
|
17
|
+
* Where a published zswarm keeps the compiled plugin, relative to the tree root.
|
|
18
|
+
*
|
|
19
|
+
* The version suffix is load-bearing. Zellij caches a permission decision per
|
|
20
|
+
* plugin URL and will not re-prompt when a build asks for more than the
|
|
21
|
+
* approved set — it denies the new one silently. A build that gains a
|
|
22
|
+
* permission has to ship under a new name; `-v2` is the ReadPaneContents one.
|
|
23
|
+
*/
|
|
24
|
+
const PREBUILT_WASM = "zswarm-bus-v3.wasm";
|
|
25
|
+
const PREBUILT_RELATIVE = join("packages", "wasm", PREBUILT_WASM);
|
|
26
|
+
const WAIT_REASONS = ["match", "idle", "timeout", "gone"];
|
|
27
|
+
function jsonLines(stdout) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const line of stdout.split("\n")) {
|
|
30
|
+
const text = line.trim();
|
|
31
|
+
if (!text.startsWith("{"))
|
|
32
|
+
continue;
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(text);
|
|
35
|
+
if (parsed && typeof parsed === "object") {
|
|
36
|
+
out.push(parsed);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Zellij's own notices share the stream; skip anything that is not ours.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function screenOf(value) {
|
|
46
|
+
if (!Array.isArray(value))
|
|
47
|
+
return "";
|
|
48
|
+
return value.filter((l) => typeof l === "string").join("\n");
|
|
49
|
+
}
|
|
50
|
+
/** A held wait answers once, late. Same scan as every other bus reply. */
|
|
51
|
+
export function parseWaitReply(stdout) {
|
|
52
|
+
for (const value of jsonLines(stdout)) {
|
|
53
|
+
if (value.ok !== true)
|
|
54
|
+
continue;
|
|
55
|
+
const reason = value.reason;
|
|
56
|
+
if (typeof reason !== "string")
|
|
57
|
+
continue;
|
|
58
|
+
if (!WAIT_REASONS.includes(reason))
|
|
59
|
+
continue;
|
|
60
|
+
return {
|
|
61
|
+
reason: reason,
|
|
62
|
+
pane: typeof value.pane === "string" ? value.pane : "",
|
|
63
|
+
screen: screenOf(value.viewport),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
export function parseChangedReply(stdout) {
|
|
69
|
+
for (const value of jsonLines(stdout)) {
|
|
70
|
+
if (value.ok !== true || !Array.isArray(value.panes))
|
|
71
|
+
continue;
|
|
72
|
+
const panes = [];
|
|
73
|
+
for (const row of value.panes) {
|
|
74
|
+
if (!row || typeof row !== "object")
|
|
75
|
+
continue;
|
|
76
|
+
const p = row;
|
|
77
|
+
if (typeof p.id !== "string")
|
|
78
|
+
continue;
|
|
79
|
+
panes.push({
|
|
80
|
+
id: p.id,
|
|
81
|
+
screen: screenOf(p.viewport),
|
|
82
|
+
changed: p.changed === true,
|
|
83
|
+
first: p.first === true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ready: value.ready === true,
|
|
88
|
+
panes,
|
|
89
|
+
missing: Array.isArray(value.missing)
|
|
90
|
+
? value.missing.filter((m) => typeof m === "string")
|
|
91
|
+
: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The plugin ships as its own package so it can version independently: Zellij
|
|
98
|
+
* caches permission grants per URL, so a build asking for a new permission has
|
|
99
|
+
* to arrive under a new filename. Resolving by name works wherever the package
|
|
100
|
+
* manager put it; the walk-up is the fallback for a checkout with nothing
|
|
101
|
+
* installed yet.
|
|
102
|
+
*/
|
|
103
|
+
function resolvePluginPackage() {
|
|
104
|
+
try {
|
|
105
|
+
return createRequire(import.meta.url).resolve(`@zswarm/wasm/${PREBUILT_WASM}`);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Absolute path of the plugin wasm, or null when there is nothing to load. */
|
|
112
|
+
export function resolveBusPlugin(env = process.env) {
|
|
113
|
+
const explicit = expandHomePath((env.ZSWARM_BUS_PLUGIN ?? "").trim().replace(/^['"]|['"]$/g, ""), env);
|
|
114
|
+
if (explicit)
|
|
115
|
+
return existsSync(explicit) ? resolve(explicit) : null;
|
|
116
|
+
const installed = resolvePluginPackage();
|
|
117
|
+
if (installed && existsSync(installed))
|
|
118
|
+
return installed;
|
|
119
|
+
// Walk up from this module so a checkout works before anything is installed.
|
|
120
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
121
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
122
|
+
const candidate = join(dir, PREBUILT_RELATIVE);
|
|
123
|
+
if (existsSync(candidate))
|
|
124
|
+
return candidate;
|
|
125
|
+
const parent = dirname(dir);
|
|
126
|
+
if (parent === dir)
|
|
127
|
+
break;
|
|
128
|
+
dir = parent;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
/** Zellij wants a `file:` url with forward slashes, including on Windows. */
|
|
133
|
+
export function busPluginUrl(pluginPath) {
|
|
134
|
+
return `file:${resolve(pluginPath).replace(/\\/g, "/")}`;
|
|
135
|
+
}
|
|
136
|
+
/** zswarm-bus → zswarm-bus-2 → zswarm-bus-3: a fresh pipe destination each time. */
|
|
137
|
+
export function nextConfigKey(key) {
|
|
138
|
+
const match = /^(.*)-(\d+)$/.exec(key);
|
|
139
|
+
if (!match)
|
|
140
|
+
return `${key}-2`;
|
|
141
|
+
return `${match[1]}-${Number(match[2]) + 1}`;
|
|
142
|
+
}
|
|
143
|
+
function toSnapshot(value) {
|
|
144
|
+
if (value.ok !== true || !Array.isArray(value.panes))
|
|
145
|
+
return null;
|
|
146
|
+
const panes = [];
|
|
147
|
+
for (const row of value.panes) {
|
|
148
|
+
if (!row || typeof row !== "object")
|
|
149
|
+
continue;
|
|
150
|
+
const p = row;
|
|
151
|
+
if (typeof p.id !== "string")
|
|
152
|
+
continue;
|
|
153
|
+
panes.push({
|
|
154
|
+
id: p.id,
|
|
155
|
+
title: typeof p.title === "string" ? p.title : "",
|
|
156
|
+
exited: p.exited === true,
|
|
157
|
+
focused: p.focused === true,
|
|
158
|
+
command: typeof p.command === "string" ? p.command : null,
|
|
159
|
+
tab: typeof p.tab === "number" ? p.tab : 0,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
ready: value.ready === true,
|
|
164
|
+
paneUpdates: typeof value.paneUpdates === "number" ? value.paneUpdates : 0,
|
|
165
|
+
tabUpdates: typeof value.tabUpdates === "number" ? value.tabUpdates : 0,
|
|
166
|
+
tabs: Array.isArray(value.tabs)
|
|
167
|
+
? value.tabs.filter((t) => typeof t === "string")
|
|
168
|
+
: [],
|
|
169
|
+
panes,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Pull the plugin's answer out of the pipe. Zellij interleaves its own notices
|
|
174
|
+
* ("Action CliPipe did not complete within 1s timeout" shows up on successful
|
|
175
|
+
* runs too), so scan for the first line that is our JSON rather than parsing
|
|
176
|
+
* the whole of stdout.
|
|
177
|
+
*/
|
|
178
|
+
export function parseBusReply(stdout) {
|
|
179
|
+
for (const line of stdout.split("\n")) {
|
|
180
|
+
const text = line.trim();
|
|
181
|
+
if (!text.startsWith("{"))
|
|
182
|
+
continue;
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = JSON.parse(text);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (parsed && typeof parsed === "object") {
|
|
191
|
+
const snapshot = toSnapshot(parsed);
|
|
192
|
+
if (snapshot)
|
|
193
|
+
return snapshot;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function stringList(value) {
|
|
199
|
+
if (!Array.isArray(value))
|
|
200
|
+
return [];
|
|
201
|
+
return value.filter((item) => typeof item === "string");
|
|
202
|
+
}
|
|
203
|
+
function toScrollback(value) {
|
|
204
|
+
if (value.ok !== true || !Array.isArray(value.panes))
|
|
205
|
+
return null;
|
|
206
|
+
const panes = [];
|
|
207
|
+
for (const row of value.panes) {
|
|
208
|
+
if (!row || typeof row !== "object")
|
|
209
|
+
continue;
|
|
210
|
+
const p = row;
|
|
211
|
+
if (typeof p.id !== "string")
|
|
212
|
+
continue;
|
|
213
|
+
panes.push({
|
|
214
|
+
id: p.id,
|
|
215
|
+
viewport: stringList(p.viewport),
|
|
216
|
+
above: stringList(p.above),
|
|
217
|
+
below: stringList(p.below),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
ready: value.ready === true,
|
|
222
|
+
panes,
|
|
223
|
+
missing: stringList(value.missing),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Same scan as parseBusReply: Zellij prints "Action CliPipe did not complete
|
|
228
|
+
* within 1s timeout" on successful pipes, so the JSON is never the whole of
|
|
229
|
+
* stdout. First line with ok===true and a panes array wins; anything else is
|
|
230
|
+
* no reply and the caller falls back.
|
|
231
|
+
*/
|
|
232
|
+
export function parseScrollbackReply(stdout) {
|
|
233
|
+
for (const line of stdout.split("\n")) {
|
|
234
|
+
const text = line.trim();
|
|
235
|
+
if (!text.startsWith("{"))
|
|
236
|
+
continue;
|
|
237
|
+
let parsed;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(text);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (parsed && typeof parsed === "object") {
|
|
245
|
+
const reply = toScrollback(parsed);
|
|
246
|
+
if (reply)
|
|
247
|
+
return reply;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
/** Rebuild dump-screen-shaped text. Byte-identity with dump-screen is unproven. */
|
|
253
|
+
export function scrollbackToScreen(pane) {
|
|
254
|
+
return pane.above.concat(pane.viewport).concat(pane.below).join("\n");
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Shape the snapshot like `list-panes` output. `cwd` and `floating` are not in
|
|
258
|
+
* the manifest Zellij pushes, so they read as unknown here — callers that need
|
|
259
|
+
* them (anything verbose) must take the polling path instead.
|
|
260
|
+
*/
|
|
261
|
+
export function busToPanes(snapshot) {
|
|
262
|
+
return snapshot.panes.map((pane) => {
|
|
263
|
+
const isPlugin = pane.id.startsWith("plugin_");
|
|
264
|
+
const numeric = Number(pane.id.slice(pane.id.indexOf("_") + 1));
|
|
265
|
+
return {
|
|
266
|
+
id: pane.id,
|
|
267
|
+
numericId: Number.isFinite(numeric) ? numeric : -1,
|
|
268
|
+
isPlugin,
|
|
269
|
+
title: pane.title,
|
|
270
|
+
command: pane.command,
|
|
271
|
+
cwd: null,
|
|
272
|
+
tabName: snapshot.tabs[pane.tab] ?? null,
|
|
273
|
+
tabId: pane.tab,
|
|
274
|
+
focused: pane.focused,
|
|
275
|
+
exited: pane.exited,
|
|
276
|
+
floating: false,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
import { resolveTab, type ZellijTab } from "./tabs.js";
|
|
3
|
+
import { type ZellijExecFn } from "./binary.js";
|
|
4
|
+
import { normalizePaneId, resolvePane, type ZellijPane } from "./panes.js";
|
|
5
|
+
import { type ZellijSessionResolve } from "./session.js";
|
|
6
|
+
export type ZellijClientOptions = {
|
|
7
|
+
exec?: ZellijExecFn;
|
|
8
|
+
zellijPath?: string;
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
};
|
|
12
|
+
/** Thin, stateless wrapper over the `zellij` binary. */
|
|
13
|
+
export declare function createZellijClient(options?: ZellijClientOptions): {
|
|
14
|
+
zellijPath: string;
|
|
15
|
+
selfPaneId: string | null;
|
|
16
|
+
/** A `file:` plugin url only names a path on the machine running Zellij. */
|
|
17
|
+
remote: boolean;
|
|
18
|
+
listSessions: () => Promise<string[]>;
|
|
19
|
+
resolveSession: (explicit?: string | null) => Promise<ZellijSessionResolve>;
|
|
20
|
+
listPanes: (session: string) => Promise<ZellijPane[]>;
|
|
21
|
+
resolvePane: typeof resolvePane;
|
|
22
|
+
injectPane: (input: {
|
|
23
|
+
session: string;
|
|
24
|
+
paneId: string;
|
|
25
|
+
text: string;
|
|
26
|
+
}) => Promise<{
|
|
27
|
+
paneId: string;
|
|
28
|
+
session: string;
|
|
29
|
+
}>;
|
|
30
|
+
dumpPane: (input: {
|
|
31
|
+
session: string;
|
|
32
|
+
paneId: string;
|
|
33
|
+
full?: boolean;
|
|
34
|
+
}) => Promise<{
|
|
35
|
+
paneId: string;
|
|
36
|
+
session: string;
|
|
37
|
+
text: string;
|
|
38
|
+
}>;
|
|
39
|
+
sendKeys: (input: {
|
|
40
|
+
session: string;
|
|
41
|
+
paneId: string;
|
|
42
|
+
keys: string[];
|
|
43
|
+
}) => Promise<{
|
|
44
|
+
paneId: string;
|
|
45
|
+
session: string;
|
|
46
|
+
keys: string[];
|
|
47
|
+
}>;
|
|
48
|
+
writeChars: (input: {
|
|
49
|
+
session: string;
|
|
50
|
+
paneId: string;
|
|
51
|
+
chars: string;
|
|
52
|
+
}) => Promise<{
|
|
53
|
+
paneId: string;
|
|
54
|
+
session: string;
|
|
55
|
+
}>;
|
|
56
|
+
closePane: (input: {
|
|
57
|
+
session: string;
|
|
58
|
+
paneId: string;
|
|
59
|
+
}) => Promise<{
|
|
60
|
+
paneId: string;
|
|
61
|
+
session: string;
|
|
62
|
+
}>;
|
|
63
|
+
newPane: (input: NewPaneInput) => Promise<{
|
|
64
|
+
session: string;
|
|
65
|
+
paneId: string | null;
|
|
66
|
+
stdout: string;
|
|
67
|
+
}>;
|
|
68
|
+
newTab: (input: NewTabInput) => Promise<{
|
|
69
|
+
session: string;
|
|
70
|
+
tabId: number | null;
|
|
71
|
+
stdout: string;
|
|
72
|
+
}>;
|
|
73
|
+
renamePane: (input: {
|
|
74
|
+
session: string;
|
|
75
|
+
paneId: string;
|
|
76
|
+
name: string;
|
|
77
|
+
}) => Promise<{
|
|
78
|
+
paneId: string;
|
|
79
|
+
session: string;
|
|
80
|
+
name: string;
|
|
81
|
+
}>;
|
|
82
|
+
renameTab: (input: {
|
|
83
|
+
session: string;
|
|
84
|
+
tabId: number;
|
|
85
|
+
name: string;
|
|
86
|
+
}) => Promise<{
|
|
87
|
+
tabId: number;
|
|
88
|
+
session: string;
|
|
89
|
+
name: string;
|
|
90
|
+
}>;
|
|
91
|
+
focusPane: (input: {
|
|
92
|
+
session: string;
|
|
93
|
+
paneId: string;
|
|
94
|
+
}) => Promise<{
|
|
95
|
+
paneId: string;
|
|
96
|
+
session: string;
|
|
97
|
+
}>;
|
|
98
|
+
listTabs: (session: string) => Promise<ZellijTab[]>;
|
|
99
|
+
dumpLayout: (session: string) => Promise<string>;
|
|
100
|
+
stackPanes: (input: {
|
|
101
|
+
session: string;
|
|
102
|
+
paneIds: string[];
|
|
103
|
+
}) => Promise<{
|
|
104
|
+
session: string;
|
|
105
|
+
paneIds: string[];
|
|
106
|
+
}>;
|
|
107
|
+
pipePlugin: (input: PipeInput & {
|
|
108
|
+
timeoutMs?: number;
|
|
109
|
+
}) => Promise<{
|
|
110
|
+
code: number;
|
|
111
|
+
stdout: string;
|
|
112
|
+
stderr: string;
|
|
113
|
+
}>;
|
|
114
|
+
scrollbackPlugin: (input: {
|
|
115
|
+
session: string;
|
|
116
|
+
url: string;
|
|
117
|
+
configKey: string;
|
|
118
|
+
panes: string[];
|
|
119
|
+
full?: boolean;
|
|
120
|
+
timeoutMs?: number;
|
|
121
|
+
}) => Promise<{
|
|
122
|
+
code: number;
|
|
123
|
+
stdout: string;
|
|
124
|
+
stderr: string;
|
|
125
|
+
}>;
|
|
126
|
+
waitPlugin: (input: {
|
|
127
|
+
session: string;
|
|
128
|
+
url: string;
|
|
129
|
+
configKey: string;
|
|
130
|
+
} & WaitRequest) => Promise<{
|
|
131
|
+
code: number;
|
|
132
|
+
stdout: string;
|
|
133
|
+
stderr: string;
|
|
134
|
+
}>;
|
|
135
|
+
changedPlugin: (input: {
|
|
136
|
+
session: string;
|
|
137
|
+
url: string;
|
|
138
|
+
configKey: string;
|
|
139
|
+
panes: string[];
|
|
140
|
+
}) => Promise<{
|
|
141
|
+
code: number;
|
|
142
|
+
stdout: string;
|
|
143
|
+
stderr: string;
|
|
144
|
+
}>;
|
|
145
|
+
launchPlugin: (input: LaunchPluginInput) => Promise<{
|
|
146
|
+
session: string;
|
|
147
|
+
paneId: string | null;
|
|
148
|
+
}>;
|
|
149
|
+
resolveTab: typeof resolveTab;
|
|
150
|
+
normalizePaneId: typeof normalizePaneId;
|
|
151
|
+
formatPeerMessage: (from: string, body: string) => string;
|
|
152
|
+
buildListPanesArgs: typeof buildListPanesArgs;
|
|
153
|
+
buildPasteArgs: typeof buildPasteArgs;
|
|
154
|
+
buildSendEnterArgs: typeof buildSendEnterArgs;
|
|
155
|
+
buildDumpArgs: typeof buildDumpArgs;
|
|
156
|
+
buildSendKeysArgs: typeof buildSendKeysArgs;
|
|
157
|
+
buildWriteCharsArgs: typeof buildWriteCharsArgs;
|
|
158
|
+
buildClosePaneArgs: typeof buildClosePaneArgs;
|
|
159
|
+
buildNewPaneArgs: typeof buildNewPaneArgs;
|
|
160
|
+
buildNewTabArgs: typeof buildNewTabArgs;
|
|
161
|
+
};
|
|
162
|
+
export type ZellijClient = ReturnType<typeof createZellijClient>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { buildClosePaneArgs, buildDumpArgs, buildDumpLayoutArgs, buildFocusPaneArgs, buildLaunchPluginArgs, buildListPanesArgs, buildListTabsArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildRenamePaneArgs, buildRenameTabArgs, buildSendEnterArgs, buildSendKeysArgs, buildStackPanesArgs, buildWriteCharsArgs, changedPayload, scrollbackPayload, waitPayload, } from "./args.js";
|
|
3
|
+
import { DEFAULT_BUS_TIMEOUT_MS, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, } from "./bus.js";
|
|
4
|
+
import { parseTabList, resolveTab } from "./tabs.js";
|
|
5
|
+
import { createSshExec } from "../exec.js";
|
|
6
|
+
import { DEFAULT_TIMEOUT_MS, NOT_FOUND_EXIT, defaultExec, resolveSshTarget, resolveZellijBinary, sanitizeZellijEnv, } from "./binary.js";
|
|
7
|
+
import { normalizePaneId, parsePaneList, resolvePane, } from "./panes.js";
|
|
8
|
+
import { parseSessionList, resolveSelfPaneId, sessionFromEnv, sessionFromList, } from "./session.js";
|
|
9
|
+
/** Thin, stateless wrapper over the `zellij` binary. */
|
|
10
|
+
export function createZellijClient(options = {}) {
|
|
11
|
+
const env = options.env ?? process.env;
|
|
12
|
+
// A remote crew never resolves a local binary.
|
|
13
|
+
const ssh = options.exec ? null : resolveSshTarget(env);
|
|
14
|
+
const zellijPath = options.zellijPath ??
|
|
15
|
+
(ssh ? `ssh://${ssh.host}/${ssh.remoteBin}` : resolveZellijBinary(env));
|
|
16
|
+
const exec = options.exec ??
|
|
17
|
+
(ssh
|
|
18
|
+
? createSshExec(ssh, sanitizeZellijEnv(env))
|
|
19
|
+
: defaultExec(zellijPath, env));
|
|
20
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
21
|
+
const selfPaneId = resolveSelfPaneId(env);
|
|
22
|
+
async function run(args, label) {
|
|
23
|
+
const result = await exec(args, { timeoutMs });
|
|
24
|
+
if (result.code === NOT_FOUND_EXIT) {
|
|
25
|
+
throw new ZellijError("zellij_missing", `zellij binary not found (${zellijPath}); install Zellij ≥ 0.42, add it to PATH, or set ZSWARM_BIN / ZSWARM_PATH`);
|
|
26
|
+
}
|
|
27
|
+
if (result.code !== 0) {
|
|
28
|
+
throw new ZellijError("zellij_failed", `${label} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
async function listSessions() {
|
|
33
|
+
const result = await run(["list-sessions", "--short", "--no-formatting"], "zellij list-sessions");
|
|
34
|
+
return parseSessionList(result.stdout);
|
|
35
|
+
}
|
|
36
|
+
async function resolveSession(explicit) {
|
|
37
|
+
return sessionFromEnv(env, explicit) ?? sessionFromList(await listSessions());
|
|
38
|
+
}
|
|
39
|
+
async function listPanes(session) {
|
|
40
|
+
const result = await run(buildListPanesArgs(session), "zellij action list-panes");
|
|
41
|
+
return parsePaneList(result.stdout);
|
|
42
|
+
}
|
|
43
|
+
async function injectPane(input) {
|
|
44
|
+
const paneId = normalizePaneId(input.paneId);
|
|
45
|
+
if (!input.text) {
|
|
46
|
+
throw new ZellijError("missing_body", "inject text required");
|
|
47
|
+
}
|
|
48
|
+
await run(buildPasteArgs(input.session, paneId, input.text), "zellij action paste");
|
|
49
|
+
await run(buildSendEnterArgs(input.session, paneId), "zellij action send-keys");
|
|
50
|
+
return { paneId, session: input.session };
|
|
51
|
+
}
|
|
52
|
+
async function dumpPane(input) {
|
|
53
|
+
const paneId = normalizePaneId(input.paneId);
|
|
54
|
+
const result = await run(buildDumpArgs(input.session, paneId, input.full), "zellij action dump-screen");
|
|
55
|
+
return { paneId, session: input.session, text: result.stdout };
|
|
56
|
+
}
|
|
57
|
+
async function sendKeys(input) {
|
|
58
|
+
const paneId = normalizePaneId(input.paneId);
|
|
59
|
+
if (input.keys.length === 0) {
|
|
60
|
+
throw new ZellijError("bad_key", "no keys given");
|
|
61
|
+
}
|
|
62
|
+
await run(buildSendKeysArgs(input.session, paneId, input.keys), "zellij action send-keys");
|
|
63
|
+
return { paneId, session: input.session, keys: input.keys };
|
|
64
|
+
}
|
|
65
|
+
async function writeChars(input) {
|
|
66
|
+
const paneId = normalizePaneId(input.paneId);
|
|
67
|
+
if (!input.chars) {
|
|
68
|
+
throw new ZellijError("missing_body", "chars required");
|
|
69
|
+
}
|
|
70
|
+
await run(buildWriteCharsArgs(input.session, paneId, input.chars), "zellij action write-chars");
|
|
71
|
+
return { paneId, session: input.session };
|
|
72
|
+
}
|
|
73
|
+
async function renamePane(input) {
|
|
74
|
+
const paneId = normalizePaneId(input.paneId);
|
|
75
|
+
if (!input.name.trim()) {
|
|
76
|
+
throw new ZellijError("missing_name", "name required");
|
|
77
|
+
}
|
|
78
|
+
await run(buildRenamePaneArgs(input.session, paneId, input.name), "zellij action rename-pane");
|
|
79
|
+
return { paneId, session: input.session, name: input.name };
|
|
80
|
+
}
|
|
81
|
+
async function renameTab(input) {
|
|
82
|
+
if (!input.name.trim()) {
|
|
83
|
+
throw new ZellijError("missing_name", "name required");
|
|
84
|
+
}
|
|
85
|
+
await run(buildRenameTabArgs(input.session, input.tabId, input.name), "zellij action rename-tab-by-id");
|
|
86
|
+
return { tabId: input.tabId, session: input.session, name: input.name };
|
|
87
|
+
}
|
|
88
|
+
async function focusPane(input) {
|
|
89
|
+
const paneId = normalizePaneId(input.paneId);
|
|
90
|
+
await run(buildFocusPaneArgs(input.session, paneId), "zellij action focus-pane-id");
|
|
91
|
+
return { paneId, session: input.session };
|
|
92
|
+
}
|
|
93
|
+
async function listTabs(session) {
|
|
94
|
+
const result = await run(buildListTabsArgs(session), "zellij action list-tabs");
|
|
95
|
+
return parseTabList(result.stdout);
|
|
96
|
+
}
|
|
97
|
+
async function dumpLayout(session) {
|
|
98
|
+
const result = await run(buildDumpLayoutArgs(session), "zellij action dump-layout");
|
|
99
|
+
return result.stdout;
|
|
100
|
+
}
|
|
101
|
+
async function stackPanes(input) {
|
|
102
|
+
if (input.paneIds.length < 2) {
|
|
103
|
+
throw new ZellijError("bad_arg", "stack needs at least two panes");
|
|
104
|
+
}
|
|
105
|
+
await run(buildStackPanesArgs(input.session, input.paneIds), "zellij action stack-panes");
|
|
106
|
+
return {
|
|
107
|
+
session: input.session,
|
|
108
|
+
paneIds: input.paneIds.map((id) => normalizePaneId(id)),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function closePane(input) {
|
|
112
|
+
const paneId = normalizePaneId(input.paneId);
|
|
113
|
+
await run(buildClosePaneArgs(input.session, paneId), "zellij action close-pane");
|
|
114
|
+
return { paneId, session: input.session };
|
|
115
|
+
}
|
|
116
|
+
/** `new-pane` prints the created pane id; `new-tab` prints a tab id instead. */
|
|
117
|
+
function parseCreatedPaneId(stdout) {
|
|
118
|
+
const match = /(terminal|plugin)_\d+/i.exec(stdout);
|
|
119
|
+
return match ? match[0].toLowerCase() : null;
|
|
120
|
+
}
|
|
121
|
+
async function newPane(input) {
|
|
122
|
+
const result = await run(buildNewPaneArgs(input), "zellij action new-pane");
|
|
123
|
+
return {
|
|
124
|
+
session: input.session,
|
|
125
|
+
paneId: parseCreatedPaneId(result.stdout),
|
|
126
|
+
stdout: result.stdout.trim(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function newTab(input) {
|
|
130
|
+
const result = await run(buildNewTabArgs(input), "zellij action new-tab");
|
|
131
|
+
const digits = /-?\d+/.exec(result.stdout);
|
|
132
|
+
return {
|
|
133
|
+
session: input.session,
|
|
134
|
+
tabId: digits ? Number(digits[0]) : null,
|
|
135
|
+
stdout: result.stdout.trim(),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Ask the event-bus plugin, without the usual failure handling: a missing or
|
|
140
|
+
* unresponsive plugin is an expected outcome the caller falls back from, not
|
|
141
|
+
* an error worth raising.
|
|
142
|
+
*/
|
|
143
|
+
async function pipePlugin(input) {
|
|
144
|
+
return exec(buildPipeArgs(input), {
|
|
145
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_BUS_TIMEOUT_MS,
|
|
146
|
+
// The answer is what we want; the process outliving it is not our problem.
|
|
147
|
+
until: (stdout) => parseBusReply(stdout) !== null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Multi-pane scrollback over the same pipe. Missing/unresponsive plugin is a
|
|
152
|
+
* value, not a throw — same fallback contract as pipePlugin. Wait on the JSON
|
|
153
|
+
* reply, not process exit: zellij pipe answers in ~40ms but stays resident
|
|
154
|
+
* when it has no terminal.
|
|
155
|
+
*/
|
|
156
|
+
async function scrollbackPlugin(input) {
|
|
157
|
+
return exec(buildPipeArgs({
|
|
158
|
+
session: input.session,
|
|
159
|
+
url: input.url,
|
|
160
|
+
configKey: input.configKey,
|
|
161
|
+
payload: scrollbackPayload({ panes: input.panes, full: input.full }),
|
|
162
|
+
}), {
|
|
163
|
+
timeoutMs: input.timeoutMs ?? DEFAULT_BUS_TIMEOUT_MS,
|
|
164
|
+
until: (stdout) => parseScrollbackReply(stdout) !== null,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A wait the plugin holds open. The transport timeout has to outlast the
|
|
169
|
+
* caller's own, or the pipe dies before the wait it is carrying.
|
|
170
|
+
*/
|
|
171
|
+
async function waitPlugin(input) {
|
|
172
|
+
const budget = (input.timeoutMs ?? 60_000) + DEFAULT_BUS_TIMEOUT_MS;
|
|
173
|
+
return exec(buildPipeArgs({
|
|
174
|
+
session: input.session,
|
|
175
|
+
url: input.url,
|
|
176
|
+
configKey: input.configKey,
|
|
177
|
+
payload: waitPayload(input),
|
|
178
|
+
}), {
|
|
179
|
+
timeoutMs: budget,
|
|
180
|
+
until: (stdout) => parseWaitReply(stdout) !== null,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function changedPlugin(input) {
|
|
184
|
+
return exec(buildPipeArgs({
|
|
185
|
+
session: input.session,
|
|
186
|
+
url: input.url,
|
|
187
|
+
configKey: input.configKey,
|
|
188
|
+
payload: changedPayload(input.panes),
|
|
189
|
+
}), {
|
|
190
|
+
timeoutMs: DEFAULT_BUS_TIMEOUT_MS,
|
|
191
|
+
until: (stdout) => parseChangedReply(stdout) !== null,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async function launchPlugin(input) {
|
|
195
|
+
const result = await run(buildLaunchPluginArgs(input), "zellij action launch-or-focus-plugin");
|
|
196
|
+
return {
|
|
197
|
+
session: input.session,
|
|
198
|
+
paneId: parseCreatedPaneId(result.stdout),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Visible prefix so peer CLIs can tell zSwarm injects from human prompts. */
|
|
202
|
+
function formatPeerMessage(from, body) {
|
|
203
|
+
const sender = from.trim() || "swarm";
|
|
204
|
+
return `[zswarm from=${sender}]\n${body.trim()}`;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
zellijPath,
|
|
208
|
+
selfPaneId,
|
|
209
|
+
/** A `file:` plugin url only names a path on the machine running Zellij. */
|
|
210
|
+
remote: ssh !== null,
|
|
211
|
+
listSessions,
|
|
212
|
+
resolveSession,
|
|
213
|
+
listPanes,
|
|
214
|
+
resolvePane,
|
|
215
|
+
injectPane,
|
|
216
|
+
dumpPane,
|
|
217
|
+
sendKeys,
|
|
218
|
+
writeChars,
|
|
219
|
+
closePane,
|
|
220
|
+
newPane,
|
|
221
|
+
newTab,
|
|
222
|
+
renamePane,
|
|
223
|
+
renameTab,
|
|
224
|
+
focusPane,
|
|
225
|
+
listTabs,
|
|
226
|
+
dumpLayout,
|
|
227
|
+
stackPanes,
|
|
228
|
+
pipePlugin,
|
|
229
|
+
scrollbackPlugin,
|
|
230
|
+
waitPlugin,
|
|
231
|
+
changedPlugin,
|
|
232
|
+
launchPlugin,
|
|
233
|
+
resolveTab,
|
|
234
|
+
normalizePaneId,
|
|
235
|
+
formatPeerMessage,
|
|
236
|
+
buildListPanesArgs,
|
|
237
|
+
buildPasteArgs,
|
|
238
|
+
buildSendEnterArgs,
|
|
239
|
+
buildDumpArgs,
|
|
240
|
+
buildSendKeysArgs,
|
|
241
|
+
buildWriteCharsArgs,
|
|
242
|
+
buildClosePaneArgs,
|
|
243
|
+
buildNewPaneArgs,
|
|
244
|
+
buildNewTabArgs,
|
|
245
|
+
};
|
|
246
|
+
}
|