@zswarm/core 0.1.3 → 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/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 +21 -11
- package/dist/ops/panes.d.ts +4 -3
- package/dist/ops/panes.js +14 -4
- package/dist/ops/serve.d.ts +9 -1
- package/dist/ops/serve.js +92 -25
- package/dist/schema.js +2 -2
- package/dist/state.js +89 -12
- 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/session.d.ts +2 -0
- package/dist/zellij/session.js +4 -0
- package/package.json +1 -1
package/dist/ops/broadcast.d.ts
CHANGED
|
@@ -16,4 +16,4 @@ export declare function selectTargets(client: ZellijClient, panes: ZellijPane[],
|
|
|
16
16
|
skipped: Skipped[];
|
|
17
17
|
};
|
|
18
18
|
/** Send one body to many panes, reporting per-pane outcomes. */
|
|
19
|
-
export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy): Promise<OpsResult>;
|
|
19
|
+
export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy, env?: NodeJS.ProcessEnv): Promise<OpsResult>;
|
package/dist/ops/broadcast.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
2
|
import { assertPaneAllowed } from "../policy.js";
|
|
3
|
-
import { deliverTo } from "./delivery.js";
|
|
3
|
+
import { deliverTo, selfPaneTitle, withSenderLabel } from "./delivery.js";
|
|
4
4
|
import { isTrue, optionalString } from "./util.js";
|
|
5
5
|
/**
|
|
6
6
|
* Pick the panes a broadcast should reach: an explicit comma list, a tab, or
|
|
@@ -65,13 +65,17 @@ export function selectTargets(client, panes, args) {
|
|
|
65
65
|
return { targets, skipped };
|
|
66
66
|
}
|
|
67
67
|
/** Send one body to many panes, reporting per-pane outcomes. */
|
|
68
|
-
export async function broadcast(client, state, args, clock, policy) {
|
|
68
|
+
export async function broadcast(client, state, args, clock, policy, env = process.env) {
|
|
69
69
|
const body = String(args.body ?? args.text ?? "");
|
|
70
70
|
if (!body.trim())
|
|
71
71
|
throw new ZellijError("missing_body", "body required");
|
|
72
72
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
73
73
|
const panes = await client.listPanes(session);
|
|
74
|
-
const
|
|
74
|
+
const labeled = withSenderLabel(args, {
|
|
75
|
+
env,
|
|
76
|
+
selfTitle: selfPaneTitle(client, panes),
|
|
77
|
+
});
|
|
78
|
+
const { targets: selected, skipped } = selectTargets(client, panes, labeled);
|
|
75
79
|
// Policy narrows the selection rather than failing the whole broadcast.
|
|
76
80
|
const targets = selected.filter((pane) => {
|
|
77
81
|
if (!policy)
|
|
@@ -90,7 +94,7 @@ export async function broadcast(client, state, args, clock, policy) {
|
|
|
90
94
|
}
|
|
91
95
|
const results = [];
|
|
92
96
|
for (const pane of targets) {
|
|
93
|
-
results.push(await deliverTo(client, state,
|
|
97
|
+
results.push(await deliverTo(client, state, labeled, {
|
|
94
98
|
session,
|
|
95
99
|
pane,
|
|
96
100
|
body,
|
|
@@ -104,7 +108,7 @@ export async function broadcast(client, state, args, clock, policy) {
|
|
|
104
108
|
ok: true,
|
|
105
109
|
data: {
|
|
106
110
|
session,
|
|
107
|
-
from:
|
|
111
|
+
from: labeled.from,
|
|
108
112
|
delivered: delivered.map((r) => r.to),
|
|
109
113
|
failed: results.filter((r) => !r.ok),
|
|
110
114
|
skipped,
|
package/dist/ops/delivery.d.ts
CHANGED
|
@@ -14,7 +14,37 @@ export type DeliveryResult = {
|
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
export type SubmitMode = "auto" | "double-enter" | "none";
|
|
17
|
-
export
|
|
17
|
+
export type SenderSource = "arg" | "env" | "title" | "default";
|
|
18
|
+
export type SenderLabel = {
|
|
19
|
+
label: string;
|
|
20
|
+
source: SenderSource;
|
|
21
|
+
};
|
|
22
|
+
/** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
|
|
23
|
+
export declare function sanitizeSenderLabel(raw: string): string;
|
|
24
|
+
export declare function selfPaneTitle(client: Pick<ZellijClient, "selfPaneId">, panes: ZellijPane[]): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
|
|
27
|
+
* then the sending pane's title when we know that pane, else `swarm`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveSender(args: Record<string, unknown>, opts?: {
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
selfTitle?: string | null;
|
|
32
|
+
}): SenderLabel;
|
|
33
|
+
export declare function senderLabel(args: Record<string, unknown>, opts?: {
|
|
34
|
+
env?: NodeJS.ProcessEnv;
|
|
35
|
+
selfTitle?: string | null;
|
|
36
|
+
}): string;
|
|
37
|
+
/** Stamp `from` so deliverTo and the op result use the same resolved label. */
|
|
38
|
+
export declare function withSenderLabel(args: Record<string, unknown>, opts?: {
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
selfTitle?: string | null;
|
|
41
|
+
}): Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
|
|
44
|
+
* leave `from` unset when it would default, so the Zellij-side process can
|
|
45
|
+
* still fill in its own pane title.
|
|
46
|
+
*/
|
|
47
|
+
export declare function attachKnownSender(args: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown>;
|
|
18
48
|
export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
|
|
19
49
|
/**
|
|
20
50
|
* Caller `submit=` always wins. With none given, the pane's harness picks
|
package/dist/ops/delivery.js
CHANGED
|
@@ -2,8 +2,62 @@ import { resolveHarness } from "../harness.js";
|
|
|
2
2
|
import { isTrue, numberArg } from "./util.js";
|
|
3
3
|
/** Cursor / Claude leave this in scrollback after a successful submit. */
|
|
4
4
|
const PASTE_MARKER = "[Pasted text";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const SENDER_LABEL_MAX = 48;
|
|
6
|
+
/** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
|
|
7
|
+
export function sanitizeSenderLabel(raw) {
|
|
8
|
+
const cleaned = raw.replace(/[\r\n[\]]+/g, " ").replace(/\s+/g, " ").trim();
|
|
9
|
+
if (!cleaned)
|
|
10
|
+
return "";
|
|
11
|
+
return cleaned.length <= SENDER_LABEL_MAX
|
|
12
|
+
? cleaned
|
|
13
|
+
: cleaned.slice(0, SENDER_LABEL_MAX).trimEnd();
|
|
14
|
+
}
|
|
15
|
+
export function selfPaneTitle(client, panes) {
|
|
16
|
+
const id = client.selfPaneId;
|
|
17
|
+
if (!id)
|
|
18
|
+
return null;
|
|
19
|
+
const title = panes.find((p) => p.id === id)?.title;
|
|
20
|
+
return title?.trim() ? title : null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
|
|
24
|
+
* then the sending pane's title when we know that pane, else `swarm`.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveSender(args, opts = {}) {
|
|
27
|
+
if (typeof args.from === "string") {
|
|
28
|
+
const explicit = sanitizeSenderLabel(args.from);
|
|
29
|
+
if (explicit)
|
|
30
|
+
return { label: explicit, source: "arg" };
|
|
31
|
+
}
|
|
32
|
+
const envFrom = sanitizeSenderLabel(opts.env?.ZSWARM_FROM ?? "");
|
|
33
|
+
if (envFrom)
|
|
34
|
+
return { label: envFrom, source: "env" };
|
|
35
|
+
const title = sanitizeSenderLabel(opts.selfTitle ?? "");
|
|
36
|
+
if (title)
|
|
37
|
+
return { label: title, source: "title" };
|
|
38
|
+
return { label: "swarm", source: "default" };
|
|
39
|
+
}
|
|
40
|
+
export function senderLabel(args, opts = {}) {
|
|
41
|
+
return resolveSender(args, opts).label;
|
|
42
|
+
}
|
|
43
|
+
/** Stamp `from` so deliverTo and the op result use the same resolved label. */
|
|
44
|
+
export function withSenderLabel(args, opts = {}) {
|
|
45
|
+
return { ...args, from: senderLabel(args, opts) };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
|
|
49
|
+
* leave `from` unset when it would default, so the Zellij-side process can
|
|
50
|
+
* still fill in its own pane title.
|
|
51
|
+
*/
|
|
52
|
+
export function attachKnownSender(args, env) {
|
|
53
|
+
const op = String(args.op ?? "");
|
|
54
|
+
if (op !== "send" && op !== "broadcast")
|
|
55
|
+
return args;
|
|
56
|
+
const sender = resolveSender(args, { env });
|
|
57
|
+
if (sender.source === "arg" || sender.source === "env") {
|
|
58
|
+
return { ...args, from: sender.label };
|
|
59
|
+
}
|
|
60
|
+
return args;
|
|
7
61
|
}
|
|
8
62
|
export function bodyText(client, args, body) {
|
|
9
63
|
return isTrue(args.raw) ? body : client.formatPeerMessage(senderLabel(args), body);
|
package/dist/ops/dispatch.js
CHANGED
|
@@ -9,7 +9,7 @@ import { broadcast } from "./broadcast.js";
|
|
|
9
9
|
import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
|
|
10
10
|
import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
|
|
11
11
|
import { peerCheckpoint, peerDiff } from "./review.js";
|
|
12
|
-
import { deliverTo } from "./delivery.js";
|
|
12
|
+
import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
|
|
13
13
|
import { assertNotPlugin, assertNotSelf, assertPaneExpects, assertWritable, } from "./guards.js";
|
|
14
14
|
import { readDeliveryLog } from "./log.js";
|
|
15
15
|
import { awaitSignal, listSignals, postSignal } from "./signals.js";
|
|
@@ -35,17 +35,23 @@ const SSH_GIT_OPS = new Set([
|
|
|
35
35
|
"diff",
|
|
36
36
|
"checkpoint",
|
|
37
37
|
]);
|
|
38
|
+
const SSH_LOCAL_BARRIER_OPS = new Set(["signal", "signals", "await"]);
|
|
38
39
|
/**
|
|
39
40
|
* SSH only forwards Zellij. Git worktrees, diffs, and checkpoints stay on this
|
|
40
41
|
* machine, so mixing them with a remote pane cwd is how you delete the wrong tree.
|
|
42
|
+
* Client-local signal barriers cannot rendezvous with a remote crew either.
|
|
41
43
|
*/
|
|
42
44
|
function assertSshGitAllowed(env, op, args) {
|
|
43
45
|
if (!env.ZSWARM_SSH?.trim() || env.ZSWARM_SERVE?.trim())
|
|
44
46
|
return;
|
|
45
47
|
const worktreeSpawn = op === "spawn" && optionalString(args.worktree);
|
|
46
|
-
if (!worktreeSpawn && !SSH_GIT_OPS.has(op))
|
|
48
|
+
if (!worktreeSpawn && !SSH_GIT_OPS.has(op) && !SSH_LOCAL_BARRIER_OPS.has(op)) {
|
|
47
49
|
return;
|
|
48
|
-
|
|
50
|
+
}
|
|
51
|
+
const why = SSH_LOCAL_BARRIER_OPS.has(op)
|
|
52
|
+
? "is a client-local barrier"
|
|
53
|
+
: "uses local git";
|
|
54
|
+
throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
|
|
49
55
|
}
|
|
50
56
|
async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
51
57
|
const to = String(args.to ?? "").trim();
|
|
@@ -113,7 +119,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
113
119
|
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
114
120
|
// just because the host env has ZSWARM_SERVE set.
|
|
115
121
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
116
|
-
return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
122
|
+
return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
117
123
|
}
|
|
118
124
|
const client = injected ?? createZellijClient({ env: deps.env });
|
|
119
125
|
switch (op) {
|
|
@@ -146,7 +152,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
146
152
|
const body = String(args.body ?? args.text ?? "");
|
|
147
153
|
if (!body.trim())
|
|
148
154
|
throw new ZellijError("missing_body", "body required");
|
|
149
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
155
|
+
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
150
156
|
assertWritable(client, pane, args, "send");
|
|
151
157
|
// A pane that dropped back to a shell will *run* the message. There is
|
|
152
158
|
// no reliable way to tell an agent from a prompt, so the caller names
|
|
@@ -156,7 +162,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
156
162
|
const screen = await client.dumpPane({ session, paneId: pane.id });
|
|
157
163
|
assertPaneExpects(screen.text, expect, pane.id);
|
|
158
164
|
}
|
|
159
|
-
const
|
|
165
|
+
const labeled = withSenderLabel(args, {
|
|
166
|
+
env,
|
|
167
|
+
selfTitle: selfPaneTitle(client, panes),
|
|
168
|
+
});
|
|
169
|
+
const result = await deliverTo(client, state(), labeled, {
|
|
160
170
|
session,
|
|
161
171
|
pane,
|
|
162
172
|
body,
|
|
@@ -171,7 +181,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
171
181
|
delivery: result.delivery,
|
|
172
182
|
session,
|
|
173
183
|
to: result.to,
|
|
174
|
-
from:
|
|
184
|
+
from: labeled.from,
|
|
175
185
|
submitted: result.submitted,
|
|
176
186
|
};
|
|
177
187
|
if (verbose)
|
|
@@ -179,7 +189,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
179
189
|
return { ok: true, data };
|
|
180
190
|
}
|
|
181
191
|
case "broadcast":
|
|
182
|
-
return await broadcast(client, state(), args, clock, policy);
|
|
192
|
+
return await broadcast(client, state(), args, clock, policy, env);
|
|
183
193
|
case "keys":
|
|
184
194
|
case "interrupt": {
|
|
185
195
|
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
@@ -326,15 +336,15 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
326
336
|
case "log":
|
|
327
337
|
return readDeliveryLog(state(), args);
|
|
328
338
|
case "rename":
|
|
329
|
-
return await renameTarget(client, args);
|
|
339
|
+
return await renameTarget(client, args, policy);
|
|
330
340
|
case "focus":
|
|
331
|
-
return await focusTarget(client, args);
|
|
341
|
+
return await focusTarget(client, args, policy);
|
|
332
342
|
case "tabs":
|
|
333
343
|
return await listTabsOp(client, args);
|
|
334
344
|
case "layout":
|
|
335
345
|
return await dumpLayoutOp(client, args);
|
|
336
346
|
case "stack":
|
|
337
|
-
return await stackTargets(client, args);
|
|
347
|
+
return await stackTargets(client, args, policy);
|
|
338
348
|
case "diff":
|
|
339
349
|
return await peerDiff(git(), args);
|
|
340
350
|
case "checkpoint":
|
package/dist/ops/panes.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
import { type Policy } from "../policy.js";
|
|
1
2
|
import type { ZellijClient } from "../zellij/client.js";
|
|
2
3
|
import type { OpsResult } from "./types.js";
|
|
3
4
|
/**
|
|
4
5
|
* Give a pane (or its tab) a stable name, so peers can be addressed as
|
|
5
6
|
* `reviewer` instead of whatever id Zellij handed out.
|
|
6
7
|
*/
|
|
7
|
-
export declare function renameTarget(client: ZellijClient, args: Record<string, unknown
|
|
8
|
-
export declare function focusTarget(client: ZellijClient, args: Record<string, unknown
|
|
8
|
+
export declare function renameTarget(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
|
9
|
+
export declare function focusTarget(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
|
9
10
|
export declare function listTabsOp(client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
|
|
10
11
|
/** The session's current layout as KDL — a spawnable description of the crew. */
|
|
11
12
|
export declare function dumpLayoutOp(client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
|
|
12
13
|
/** Collapse several peers into one stack so a big crew stays readable. */
|
|
13
|
-
export declare function stackTargets(client: ZellijClient, args: Record<string, unknown
|
|
14
|
+
export declare function stackTargets(client: ZellijClient, args: Record<string, unknown>, policy?: Policy): Promise<OpsResult>;
|
package/dist/ops/panes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { assertPaneAllowed } from "../policy.js";
|
|
2
3
|
import { dumpMaxChars, optionalString, truncateDumpText } from "./util.js";
|
|
3
4
|
async function session(client, args) {
|
|
4
5
|
const resolved = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
@@ -8,7 +9,7 @@ async function session(client, args) {
|
|
|
8
9
|
* Give a pane (or its tab) a stable name, so peers can be addressed as
|
|
9
10
|
* `reviewer` instead of whatever id Zellij handed out.
|
|
10
11
|
*/
|
|
11
|
-
export async function renameTarget(client, args) {
|
|
12
|
+
export async function renameTarget(client, args, policy) {
|
|
12
13
|
const name = optionalString(args.name);
|
|
13
14
|
if (!name)
|
|
14
15
|
throw new ZellijError("missing_name", "name required");
|
|
@@ -30,18 +31,22 @@ export async function renameTarget(client, args) {
|
|
|
30
31
|
if (!to)
|
|
31
32
|
throw new ZellijError("missing_peer", "to (or tab) required");
|
|
32
33
|
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
34
|
+
if (policy)
|
|
35
|
+
assertPaneAllowed(policy, pane, "rename");
|
|
33
36
|
await client.renamePane({ session: sess, paneId: pane.id, name });
|
|
34
37
|
return {
|
|
35
38
|
ok: true,
|
|
36
39
|
data: { session: sess, to: pane.id, was: pane.title, name },
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
|
-
export async function focusTarget(client, args) {
|
|
42
|
+
export async function focusTarget(client, args, policy) {
|
|
40
43
|
const to = optionalString(args.to);
|
|
41
44
|
if (!to)
|
|
42
45
|
throw new ZellijError("missing_peer", "to required");
|
|
43
46
|
const sess = await session(client, args);
|
|
44
47
|
const pane = client.resolvePane(await client.listPanes(sess), to);
|
|
48
|
+
if (policy)
|
|
49
|
+
assertPaneAllowed(policy, pane, "focus");
|
|
45
50
|
// Zellij exits non-zero when asked to focus the already-focused pane.
|
|
46
51
|
if (pane.focused) {
|
|
47
52
|
return {
|
|
@@ -94,7 +99,7 @@ export async function dumpLayoutOp(client, args) {
|
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
101
|
/** Collapse several peers into one stack so a big crew stays readable. */
|
|
97
|
-
export async function stackTargets(client, args) {
|
|
102
|
+
export async function stackTargets(client, args, policy) {
|
|
98
103
|
const list = optionalString(args.to);
|
|
99
104
|
if (!list)
|
|
100
105
|
throw new ZellijError("missing_peer", "to (comma list) required");
|
|
@@ -104,7 +109,12 @@ export async function stackTargets(client, args) {
|
|
|
104
109
|
.split(",")
|
|
105
110
|
.map((k) => k.trim())
|
|
106
111
|
.filter(Boolean)
|
|
107
|
-
.map((key) =>
|
|
112
|
+
.map((key) => {
|
|
113
|
+
const pane = client.resolvePane(panes, key);
|
|
114
|
+
if (policy)
|
|
115
|
+
assertPaneAllowed(policy, pane, "stack");
|
|
116
|
+
return pane.id;
|
|
117
|
+
});
|
|
108
118
|
const unique = [...new Set(ids)];
|
|
109
119
|
const stacked = await client.stackPanes({ session: sess, paneIds: unique });
|
|
110
120
|
return { ok: true, data: { session: sess, stacked: stacked.paneIds } };
|
package/dist/ops/serve.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export declare const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
|
3
3
|
export declare const SERVE_TASK_NAME = "zswarm-serve";
|
|
4
4
|
/** One JSONL request; a client that never sends newline cannot grow forever. */
|
|
5
5
|
export declare const SERVE_MAX_REQUEST_BYTES: number;
|
|
6
|
+
/** Drop a socket that never finishes a JSONL line. */
|
|
7
|
+
export declare const SERVE_IDLE_TIMEOUT_MS = 30000;
|
|
8
|
+
/** Concurrent TCP clients, including in-flight wait ops. */
|
|
9
|
+
export declare const SERVE_MAX_CONNECTIONS = 32;
|
|
6
10
|
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
7
11
|
export declare const SERVE_CALL_TIMEOUT_CAP_MS: number;
|
|
8
12
|
export declare function isLoopbackHost(host: string): boolean;
|
|
@@ -20,6 +24,8 @@ export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult
|
|
|
20
24
|
export type StartServeOptions = {
|
|
21
25
|
token?: string;
|
|
22
26
|
maxRequestBytes?: number;
|
|
27
|
+
idleTimeoutMs?: number;
|
|
28
|
+
maxConnections?: number;
|
|
23
29
|
};
|
|
24
30
|
export declare function startServe(listen: string | undefined, dispatch: ServeDispatch, options?: StartServeOptions): Promise<{
|
|
25
31
|
label: string;
|
|
@@ -30,12 +36,14 @@ export declare function parseServeTarget(raw: string): {
|
|
|
30
36
|
port: number;
|
|
31
37
|
};
|
|
32
38
|
export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
|
|
33
|
-
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
|
|
39
|
+
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
|
|
34
40
|
export declare function installServeLogon(input: {
|
|
35
41
|
listen?: string;
|
|
36
42
|
execPath?: string;
|
|
37
43
|
scriptPath?: string;
|
|
38
44
|
platform?: NodeJS.Platform;
|
|
45
|
+
token?: string;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
39
47
|
}): Promise<{
|
|
40
48
|
task: string;
|
|
41
49
|
listen: string;
|
package/dist/ops/serve.js
CHANGED
|
@@ -6,6 +6,10 @@ export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
|
6
6
|
export const SERVE_TASK_NAME = "zswarm-serve";
|
|
7
7
|
/** One JSONL request; a client that never sends newline cannot grow forever. */
|
|
8
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;
|
|
9
13
|
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
10
14
|
export const SERVE_CALL_TIMEOUT_CAP_MS = 16 * 60_000;
|
|
11
15
|
const SERVE_TOKEN_FIELD = "serveToken";
|
|
@@ -81,15 +85,37 @@ function takeServeToken(args) {
|
|
|
81
85
|
}
|
|
82
86
|
export function startServe(listen, dispatch, options = {}) {
|
|
83
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
|
+
}
|
|
84
91
|
const token = options.token?.trim() || undefined;
|
|
85
|
-
if (!token
|
|
86
|
-
return Promise.reject(new ZellijError("serve_auth",
|
|
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"));
|
|
87
94
|
}
|
|
88
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;
|
|
89
98
|
return new Promise((resolve, reject) => {
|
|
99
|
+
const sockets = new Set();
|
|
90
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
|
+
});
|
|
91
112
|
let buf = "";
|
|
113
|
+
let draining = false;
|
|
92
114
|
socket.setEncoding("utf8");
|
|
115
|
+
socket.setTimeout(idleTimeoutMs);
|
|
116
|
+
socket.on("timeout", () => {
|
|
117
|
+
socket.destroy();
|
|
118
|
+
});
|
|
93
119
|
socket.on("data", (chunk) => {
|
|
94
120
|
buf += chunk;
|
|
95
121
|
if (buf.length > maxRequestBytes) {
|
|
@@ -106,29 +132,48 @@ export function startServe(listen, dispatch, options = {}) {
|
|
|
106
132
|
void drain();
|
|
107
133
|
});
|
|
108
134
|
async function drain() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
155
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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`);
|
|
123
162
|
}
|
|
124
163
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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);
|
|
130
176
|
}
|
|
131
|
-
nl = buf.indexOf("\n");
|
|
132
177
|
}
|
|
133
178
|
}
|
|
134
179
|
});
|
|
@@ -139,6 +184,8 @@ export function startServe(listen, dispatch, options = {}) {
|
|
|
139
184
|
resolve({
|
|
140
185
|
label: formatListenLabel(host, actualPort),
|
|
141
186
|
close: () => new Promise((done, fail) => {
|
|
187
|
+
for (const open of sockets)
|
|
188
|
+
open.destroy();
|
|
142
189
|
server.close((err) => (err ? fail(err) : done()));
|
|
143
190
|
}),
|
|
144
191
|
});
|
|
@@ -205,8 +252,24 @@ export function callServe(target, args, timeoutMs = 15_000, token) {
|
|
|
205
252
|
});
|
|
206
253
|
});
|
|
207
254
|
}
|
|
208
|
-
export function serveLogonCommand(execPath, scriptPath, listen) {
|
|
209
|
-
|
|
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;
|
|
210
273
|
}
|
|
211
274
|
function runPowerShell(script) {
|
|
212
275
|
return new Promise((resolve) => {
|
|
@@ -234,13 +297,17 @@ export async function installServeLogon(input) {
|
|
|
234
297
|
if ((input.platform ?? process.platform) !== "win32") {
|
|
235
298
|
throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
|
|
236
299
|
}
|
|
237
|
-
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);
|
|
238
305
|
const execPath = input.execPath ?? process.execPath;
|
|
239
306
|
const scriptPath = input.scriptPath ?? process.argv[1];
|
|
240
307
|
if (!scriptPath) {
|
|
241
308
|
throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
|
|
242
309
|
}
|
|
243
|
-
const command = serveLogonCommand(execPath, scriptPath, label);
|
|
310
|
+
const command = serveLogonCommand(execPath, scriptPath, label, token);
|
|
244
311
|
const commandB64 = Buffer.from(command, "utf8").toString("base64");
|
|
245
312
|
const script = [
|
|
246
313
|
"$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
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 (+ ZSWARM_SERVE_TOKEN off loopback).", "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, ZSWARM_SERVE_TOKEN", "");
|
|
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. */
|
package/dist/state.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { appendFileSync, closeSync, mkdirSync, openSync, 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
6
|
const SIGNALS_LOCK = "signals.lock";
|
|
7
7
|
const CURSORS_FILE = "cursors.json";
|
|
8
|
+
const CURSORS_LOCK = "cursors.lock";
|
|
8
9
|
const BUS_FILE = "bus.json";
|
|
9
10
|
/** Keeps the log bounded without needing a rotation daemon. */
|
|
10
11
|
const LOG_TAIL_BYTES = 512 * 1024;
|
|
@@ -77,32 +78,104 @@ export function createStateStore(options = {}) {
|
|
|
77
78
|
}
|
|
78
79
|
return out;
|
|
79
80
|
}
|
|
80
|
-
function
|
|
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) {
|
|
81
146
|
ensureDir();
|
|
82
|
-
const lockPath = join(dir,
|
|
147
|
+
const lockPath = join(dir, lockName);
|
|
83
148
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
84
149
|
while (true) {
|
|
85
150
|
try {
|
|
86
151
|
const fd = openSync(lockPath, "wx");
|
|
87
152
|
try {
|
|
153
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
88
154
|
return fn();
|
|
89
155
|
}
|
|
90
156
|
finally {
|
|
91
157
|
closeSync(fd);
|
|
92
|
-
|
|
158
|
+
unlinkLock(lockPath);
|
|
93
159
|
}
|
|
94
160
|
}
|
|
95
161
|
catch (err) {
|
|
96
162
|
const code = err.code;
|
|
97
|
-
if (code
|
|
163
|
+
if (!lockBusy(code))
|
|
98
164
|
throw err;
|
|
165
|
+
if (code === "EEXIST" && lockIsStale(lockPath)) {
|
|
166
|
+
unlinkLock(lockPath);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
99
169
|
if (Date.now() >= deadline) {
|
|
100
|
-
throw new Error(`timed out waiting for ${
|
|
170
|
+
throw new Error(`timed out waiting for ${lockName}`);
|
|
101
171
|
}
|
|
102
172
|
sleepSync(10);
|
|
103
173
|
}
|
|
104
174
|
}
|
|
105
175
|
}
|
|
176
|
+
function withSignalsLock(fn) {
|
|
177
|
+
return withFileLock(SIGNALS_LOCK, fn);
|
|
178
|
+
}
|
|
106
179
|
function readSignals() {
|
|
107
180
|
return readJson(SIGNALS_FILE, {});
|
|
108
181
|
}
|
|
@@ -136,14 +209,18 @@ export function createStateStore(options = {}) {
|
|
|
136
209
|
return all[key] ?? null;
|
|
137
210
|
}
|
|
138
211
|
function writeCursor(key, text) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
212
|
+
withFileLock(CURSORS_LOCK, () => {
|
|
213
|
+
const all = readJson(CURSORS_FILE, {});
|
|
214
|
+
all[key] = text;
|
|
215
|
+
writeJson(CURSORS_FILE, all);
|
|
216
|
+
});
|
|
142
217
|
}
|
|
143
218
|
function clearCursor(key) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
219
|
+
withFileLock(CURSORS_LOCK, () => {
|
|
220
|
+
const all = readJson(CURSORS_FILE, {});
|
|
221
|
+
delete all[key];
|
|
222
|
+
writeJson(CURSORS_FILE, all);
|
|
223
|
+
});
|
|
147
224
|
}
|
|
148
225
|
function readBus() {
|
|
149
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) {
|
package/dist/zellij/session.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type ZellijSessionResolve = {
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function resolveSelfPaneId(env?: NodeJS.ProcessEnv): string | null;
|
|
10
10
|
export declare function parseSessionList(stdout: string): string[];
|
|
11
|
+
/** Zellij exits 1 and prints this when the machine simply has no sessions. */
|
|
12
|
+
export declare function isZellijNoSessionsOutput(stdout: string, stderr: string): boolean;
|
|
11
13
|
/** Session from an explicit argument or the environment, before asking Zellij. */
|
|
12
14
|
export declare function sessionFromEnv(env: NodeJS.ProcessEnv, explicit?: string | null): ZellijSessionResolve | null;
|
|
13
15
|
/** Pick a session when the environment gave no answer. */
|
package/dist/zellij/session.js
CHANGED
|
@@ -19,6 +19,10 @@ export function parseSessionList(stdout) {
|
|
|
19
19
|
.map((l) => l.trim())
|
|
20
20
|
.filter(Boolean);
|
|
21
21
|
}
|
|
22
|
+
/** Zellij exits 1 and prints this when the machine simply has no sessions. */
|
|
23
|
+
export function isZellijNoSessionsOutput(stdout, stderr) {
|
|
24
|
+
return /no active zellij sessions found/i.test(`${stderr}\n${stdout}`);
|
|
25
|
+
}
|
|
22
26
|
/** Session from an explicit argument or the environment, before asking Zellij. */
|
|
23
27
|
export function sessionFromEnv(env, explicit) {
|
|
24
28
|
const arg = explicit?.trim();
|