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