@zswarm/core 0.1.3 → 0.1.5
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 +18 -2
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -4
- package/dist/ops/broadcast.d.ts +1 -1
- package/dist/ops/broadcast.js +9 -5
- package/dist/ops/bus.d.ts +10 -7
- package/dist/ops/bus.js +119 -86
- package/dist/ops/delivery.d.ts +31 -1
- package/dist/ops/delivery.js +53 -2
- package/dist/ops/dispatch.js +40 -18
- package/dist/ops/panes.d.ts +4 -3
- package/dist/ops/panes.js +14 -4
- package/dist/ops/serve.d.ts +10 -1
- package/dist/ops/serve.js +99 -26
- package/dist/ops/types.d.ts +2 -0
- package/dist/ops/util.d.ts +1 -0
- package/dist/ops/util.js +5 -0
- package/dist/ops/wait.d.ts +8 -1
- package/dist/ops/wait.js +7 -3
- package/dist/schema.js +6 -3
- package/dist/state.d.ts +7 -6
- package/dist/state.js +160 -23
- package/dist/zellij/args.d.ts +2 -0
- package/dist/zellij/args.js +1 -0
- package/dist/zellij/binary.d.ts +3 -1
- package/dist/zellij/binary.js +34 -2
- package/dist/zellij/bus.d.ts +9 -0
- package/dist/zellij/bus.js +20 -0
- package/dist/zellij/client.d.ts +1 -0
- package/dist/zellij/client.js +17 -3
- package/dist/zellij/session.d.ts +2 -0
- package/dist/zellij/session.js +4 -0
- package/package.json +2 -2
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";
|
|
@@ -17,7 +17,7 @@ import { spawnPane } from "./spawn.js";
|
|
|
17
17
|
import { peerStatus } from "./status.js";
|
|
18
18
|
import { tailPane } from "./tail.js";
|
|
19
19
|
import { OP_NAMES } from "../schema.js";
|
|
20
|
-
import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, truncateDumpText, } from "./util.js";
|
|
20
|
+
import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
21
21
|
import { waitForPane } from "./wait.js";
|
|
22
22
|
import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
|
|
23
23
|
import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.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();
|
|
@@ -79,10 +85,23 @@ async function resolveTarget(client, args, state, clock, env, policy, op) {
|
|
|
79
85
|
export async function dispatchZswarm(args, injected, deps = {}) {
|
|
80
86
|
const op = String(args.op ?? "");
|
|
81
87
|
const verbose = isVerbose(args);
|
|
88
|
+
const signal = deps.signal;
|
|
89
|
+
throwIfAborted(signal);
|
|
82
90
|
const clock = {
|
|
83
91
|
now: deps.now ?? (() => Date.now()),
|
|
84
92
|
sleep: deps.sleep ??
|
|
85
|
-
((ms) => new Promise((
|
|
93
|
+
((ms) => new Promise((resolve, reject) => {
|
|
94
|
+
if (signal?.aborted) {
|
|
95
|
+
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const timer = setTimeout(resolve, ms);
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(new ZellijError("cancelled", "operation cancelled"));
|
|
102
|
+
};
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
})),
|
|
86
105
|
};
|
|
87
106
|
// Only the worktree ops need git, so the client is built on demand.
|
|
88
107
|
let gitClient = deps.git ?? null;
|
|
@@ -113,9 +132,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
113
132
|
// An injected client is a unit-test (or in-process) Zellij; do not skip it
|
|
114
133
|
// just because the host env has ZSWARM_SERVE set.
|
|
115
134
|
if (!injected && env.ZSWARM_SERVE?.trim()) {
|
|
116
|
-
return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
135
|
+
return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
|
|
117
136
|
}
|
|
118
|
-
const client = injected ?? createZellijClient({ env: deps.env });
|
|
137
|
+
const client = injected ?? createZellijClient({ env: deps.env, signal });
|
|
119
138
|
switch (op) {
|
|
120
139
|
case "sessions": {
|
|
121
140
|
const sessions = await client.listSessions();
|
|
@@ -146,7 +165,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
146
165
|
const body = String(args.body ?? args.text ?? "");
|
|
147
166
|
if (!body.trim())
|
|
148
167
|
throw new ZellijError("missing_body", "body required");
|
|
149
|
-
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
168
|
+
const { session, pane, panes } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
150
169
|
assertWritable(client, pane, args, "send");
|
|
151
170
|
// A pane that dropped back to a shell will *run* the message. There is
|
|
152
171
|
// no reliable way to tell an agent from a prompt, so the caller names
|
|
@@ -156,7 +175,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
156
175
|
const screen = await client.dumpPane({ session, paneId: pane.id });
|
|
157
176
|
assertPaneExpects(screen.text, expect, pane.id);
|
|
158
177
|
}
|
|
159
|
-
const
|
|
178
|
+
const labeled = withSenderLabel(args, {
|
|
179
|
+
env,
|
|
180
|
+
selfTitle: selfPaneTitle(client, panes),
|
|
181
|
+
});
|
|
182
|
+
const result = await deliverTo(client, state(), labeled, {
|
|
160
183
|
session,
|
|
161
184
|
pane,
|
|
162
185
|
body,
|
|
@@ -171,7 +194,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
171
194
|
delivery: result.delivery,
|
|
172
195
|
session,
|
|
173
196
|
to: result.to,
|
|
174
|
-
from:
|
|
197
|
+
from: labeled.from,
|
|
175
198
|
submitted: result.submitted,
|
|
176
199
|
};
|
|
177
200
|
if (verbose)
|
|
@@ -179,7 +202,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
179
202
|
return { ok: true, data };
|
|
180
203
|
}
|
|
181
204
|
case "broadcast":
|
|
182
|
-
return await broadcast(client, state(), args, clock, policy);
|
|
205
|
+
return await broadcast(client, state(), args, clock, policy, env);
|
|
183
206
|
case "keys":
|
|
184
207
|
case "interrupt": {
|
|
185
208
|
const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
|
|
@@ -268,15 +291,14 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
268
291
|
// no engine — and then this is the loop that runs.
|
|
269
292
|
const viaBus = isTrue(args.regex)
|
|
270
293
|
? undefined
|
|
271
|
-
: () => busWait(client, state(), target.session, {
|
|
294
|
+
: (timing) => busWait(client, state(), target.session, {
|
|
272
295
|
pane: target.pane.id,
|
|
273
296
|
for: waitMode(args),
|
|
274
297
|
match: typeof args.match === "string" ? args.match : null,
|
|
275
298
|
ignoreCase: isTrue(args.ignoreCase),
|
|
276
|
-
|
|
277
|
-
timeoutMs: Number(args.timeoutMs ?? 60_000),
|
|
299
|
+
...timing,
|
|
278
300
|
}, deps.env);
|
|
279
|
-
return await waitForPane(client, target, args, clock, viaBus);
|
|
301
|
+
return await waitForPane(client, target, args, clock, viaBus, deps.signal);
|
|
280
302
|
}
|
|
281
303
|
case "status": {
|
|
282
304
|
const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
|
|
@@ -326,15 +348,15 @@ export async function dispatchZswarm(args, injected, deps = {}) {
|
|
|
326
348
|
case "log":
|
|
327
349
|
return readDeliveryLog(state(), args);
|
|
328
350
|
case "rename":
|
|
329
|
-
return await renameTarget(client, args);
|
|
351
|
+
return await renameTarget(client, args, policy);
|
|
330
352
|
case "focus":
|
|
331
|
-
return await focusTarget(client, args);
|
|
353
|
+
return await focusTarget(client, args, policy);
|
|
332
354
|
case "tabs":
|
|
333
355
|
return await listTabsOp(client, args);
|
|
334
356
|
case "layout":
|
|
335
357
|
return await dumpLayoutOp(client, args);
|
|
336
358
|
case "stack":
|
|
337
|
-
return await stackTargets(client, args);
|
|
359
|
+
return await stackTargets(client, args, policy);
|
|
338
360
|
case "diff":
|
|
339
361
|
return await peerDiff(git(), args);
|
|
340
362
|
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,15 @@ 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
|
|
39
|
+
export declare function redactServeSecret(command: string, token: string): string;
|
|
40
|
+
export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
|
|
34
41
|
export declare function installServeLogon(input: {
|
|
35
42
|
listen?: string;
|
|
36
43
|
execPath?: string;
|
|
37
44
|
scriptPath?: string;
|
|
38
45
|
platform?: NodeJS.Platform;
|
|
46
|
+
token?: string;
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
39
48
|
}): Promise<{
|
|
40
49
|
task: string;
|
|
41
50
|
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,30 @@ export function callServe(target, args, timeoutMs = 15_000, token) {
|
|
|
205
252
|
});
|
|
206
253
|
});
|
|
207
254
|
}
|
|
208
|
-
export function
|
|
209
|
-
|
|
255
|
+
export function redactServeSecret(command, token) {
|
|
256
|
+
const secret = token.trim();
|
|
257
|
+
if (!secret)
|
|
258
|
+
return command;
|
|
259
|
+
return command.split(secret).join("***");
|
|
260
|
+
}
|
|
261
|
+
export function serveLogonCommand(execPath, scriptPath, listen, token) {
|
|
262
|
+
const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
|
|
263
|
+
const secret = token?.trim();
|
|
264
|
+
if (!secret)
|
|
265
|
+
return launch;
|
|
266
|
+
if (/[\0\r\n"%]/.test(secret)) {
|
|
267
|
+
throw new ZellijError("bad_arg", "ZSWARM_SERVE_TOKEN must not contain quotes, newlines, or %");
|
|
268
|
+
}
|
|
269
|
+
return `set "ZSWARM_SERVE_TOKEN=${secret}"&& ${launch}`;
|
|
270
|
+
}
|
|
271
|
+
function serveInstallToken(input) {
|
|
272
|
+
const raw = input.token ??
|
|
273
|
+
(input.env ? input.env.ZSWARM_SERVE_TOKEN : process.env.ZSWARM_SERVE_TOKEN);
|
|
274
|
+
const secret = raw?.trim() ?? "";
|
|
275
|
+
if (!secret) {
|
|
276
|
+
throw new ZellijError("serve_auth", "zswarm serve requires ZSWARM_SERVE_TOKEN; another local OS user can connect to 127.0.0.1");
|
|
277
|
+
}
|
|
278
|
+
return secret;
|
|
210
279
|
}
|
|
211
280
|
function runPowerShell(script) {
|
|
212
281
|
return new Promise((resolve) => {
|
|
@@ -234,13 +303,17 @@ export async function installServeLogon(input) {
|
|
|
234
303
|
if ((input.platform ?? process.platform) !== "win32") {
|
|
235
304
|
throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
|
|
236
305
|
}
|
|
237
|
-
const { label } = parseListenAddress(input.listen);
|
|
306
|
+
const { host, label } = parseListenAddress(input.listen);
|
|
307
|
+
if (!isLoopbackHost(host)) {
|
|
308
|
+
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)`);
|
|
309
|
+
}
|
|
310
|
+
const token = serveInstallToken(input);
|
|
238
311
|
const execPath = input.execPath ?? process.execPath;
|
|
239
312
|
const scriptPath = input.scriptPath ?? process.argv[1];
|
|
240
313
|
if (!scriptPath) {
|
|
241
314
|
throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
|
|
242
315
|
}
|
|
243
|
-
const command = serveLogonCommand(execPath, scriptPath, label);
|
|
316
|
+
const command = serveLogonCommand(execPath, scriptPath, label, token);
|
|
244
317
|
const commandB64 = Buffer.from(command, "utf8").toString("base64");
|
|
245
318
|
const script = [
|
|
246
319
|
"$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
@@ -256,7 +329,7 @@ export async function installServeLogon(input) {
|
|
|
256
329
|
if (result.code !== 0) {
|
|
257
330
|
throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
258
331
|
}
|
|
259
|
-
return { task: SERVE_TASK_NAME, listen: label, command };
|
|
332
|
+
return { task: SERVE_TASK_NAME, listen: label, command: redactServeSecret(command, token) };
|
|
260
333
|
}
|
|
261
334
|
export async function uninstallServeLogon(input = {}) {
|
|
262
335
|
if ((input.platform ?? process.platform) !== "win32") {
|
package/dist/ops/types.d.ts
CHANGED
package/dist/ops/util.d.ts
CHANGED
|
@@ -45,4 +45,5 @@ export declare function numberArg(args: Record<string, unknown>, key: string, fa
|
|
|
45
45
|
min: number;
|
|
46
46
|
max: number;
|
|
47
47
|
}): number;
|
|
48
|
+
export declare function throwIfAborted(signal?: AbortSignal): void;
|
|
48
49
|
export declare function dumpMaxChars(args: Record<string, unknown>, fallback?: number): number;
|
package/dist/ops/util.js
CHANGED
|
@@ -73,6 +73,11 @@ export function numberArg(args, key, fallback, limits) {
|
|
|
73
73
|
}
|
|
74
74
|
return Math.min(limits.max, Math.max(limits.min, Math.floor(n)));
|
|
75
75
|
}
|
|
76
|
+
export function throwIfAborted(signal) {
|
|
77
|
+
if (signal?.aborted) {
|
|
78
|
+
throw new ZellijError("cancelled", "operation cancelled");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
76
81
|
export function dumpMaxChars(args, fallback = DEFAULT_DUMP_MAX_CHARS) {
|
|
77
82
|
if (args.max === undefined || args.max === null || args.max === "") {
|
|
78
83
|
return fallback;
|
package/dist/ops/wait.d.ts
CHANGED
|
@@ -5,6 +5,13 @@ import type { Clock, OpsResult } from "./types.js";
|
|
|
5
5
|
export declare function buildMatcher(args: Record<string, unknown>): ((text: string) => boolean) | null;
|
|
6
6
|
/** Held-pipe wait answer, shaped by `parseWaitReply` in `zellij/bus.ts`. */
|
|
7
7
|
export type { BusWait };
|
|
8
|
+
/** Clamped timing the bus path must use — never the raw request numbers. */
|
|
9
|
+
export type WaitBusTiming = {
|
|
10
|
+
idleMs: number;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
pollMs: number;
|
|
13
|
+
full: boolean;
|
|
14
|
+
};
|
|
8
15
|
/**
|
|
9
16
|
* Poll a pane's screen until it goes quiet, prints a match, or the deadline
|
|
10
17
|
* passes — so callers stop re-dumping in a loop of their own.
|
|
@@ -16,4 +23,4 @@ export type { BusWait };
|
|
|
16
23
|
export declare function waitForPane(client: ZellijClient, target: {
|
|
17
24
|
session: string;
|
|
18
25
|
pane: ZellijPane;
|
|
19
|
-
}, args: Record<string, unknown>, clock: Clock, waitViaBus?: () => Promise<BusWait | null
|
|
26
|
+
}, args: Record<string, unknown>, clock: Clock, waitViaBus?: (timing: WaitBusTiming) => Promise<BusWait | null>, signal?: AbortSignal): Promise<OpsResult>;
|
package/dist/ops/wait.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
-
import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, truncateDumpText, } from "./util.js";
|
|
2
|
+
import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
3
3
|
const WAIT_DEFAULTS = {
|
|
4
4
|
idleMs: 2_000,
|
|
5
5
|
pollMs: 150,
|
|
@@ -56,7 +56,7 @@ function waitResult(reason, ctx) {
|
|
|
56
56
|
* Null (plugin missing or silent) falls back to dump-screen polling. The
|
|
57
57
|
* pipe's own timeout belongs to the transport; this op does not cap it.
|
|
58
58
|
*/
|
|
59
|
-
export async function waitForPane(client, target, args, clock, waitViaBus) {
|
|
59
|
+
export async function waitForPane(client, target, args, clock, waitViaBus, signal) {
|
|
60
60
|
const { session, pane } = target;
|
|
61
61
|
const matcher = buildMatcher(args);
|
|
62
62
|
const requested = optionalString(args.for) ?? (matcher ? "match" : "idle");
|
|
@@ -72,8 +72,11 @@ export async function waitForPane(client, target, args, clock, waitViaBus) {
|
|
|
72
72
|
const pollMs = numberArg(args, "pollMs", WAIT_DEFAULTS.pollMs, WAIT_LIMITS.pollMs);
|
|
73
73
|
const timeoutMs = numberArg(args, "timeoutMs", WAIT_DEFAULTS.timeoutMs, WAIT_LIMITS.timeoutMs);
|
|
74
74
|
const started = clock.now();
|
|
75
|
+
const full = isTrue(args.full);
|
|
75
76
|
if (waitViaBus) {
|
|
76
|
-
|
|
77
|
+
throwIfAborted(signal);
|
|
78
|
+
const reply = await waitViaBus({ idleMs, timeoutMs, pollMs, full });
|
|
79
|
+
throwIfAborted(signal);
|
|
77
80
|
// `gone` is filtered upstream: a pane that vanished mid-wait is reported by
|
|
78
81
|
// the polling path, which has the error message for it.
|
|
79
82
|
if (reply && reply.reason !== "gone") {
|
|
@@ -96,6 +99,7 @@ export async function waitForPane(client, target, args, clock, waitViaBus) {
|
|
|
96
99
|
let polls = 0;
|
|
97
100
|
let changes = 0;
|
|
98
101
|
for (;;) {
|
|
102
|
+
throwIfAborted(signal);
|
|
99
103
|
const dumped = await client.dumpPane({
|
|
100
104
|
session,
|
|
101
105
|
paneId: pane.id,
|
package/dist/schema.js
CHANGED
|
@@ -42,6 +42,9 @@ export const TARGET_OPS = [
|
|
|
42
42
|
"keys",
|
|
43
43
|
"interrupt",
|
|
44
44
|
"close",
|
|
45
|
+
"rename",
|
|
46
|
+
"focus",
|
|
47
|
+
"stack",
|
|
45
48
|
];
|
|
46
49
|
export const PARAMS = [
|
|
47
50
|
{
|
|
@@ -150,7 +153,7 @@ export const PARAMS = [
|
|
|
150
153
|
name: "from",
|
|
151
154
|
type: "string",
|
|
152
155
|
flags: ["--from", "-f"],
|
|
153
|
-
description: "send: sender label in the [zswarm from=…] prefix",
|
|
156
|
+
description: "send: sender label in the [zswarm from=…] prefix (default: ZSWARM_FROM, else the sending pane's title, else swarm)",
|
|
154
157
|
},
|
|
155
158
|
{
|
|
156
159
|
name: "raw",
|
|
@@ -382,7 +385,7 @@ export const PARAMS = [
|
|
|
382
385
|
name: "force",
|
|
383
386
|
type: "boolean",
|
|
384
387
|
flags: ["--force"],
|
|
385
|
-
description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus:
|
|
388
|
+
description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: close orphan bus panes and reload this session's plugin",
|
|
386
389
|
},
|
|
387
390
|
{
|
|
388
391
|
name: "listen",
|
|
@@ -447,7 +450,7 @@ export function cliUsage() {
|
|
|
447
450
|
const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
|
|
448
451
|
lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
|
|
449
452
|
}
|
|
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
|
|
453
|
+
lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE (+ ZSWARM_SERVE_TOKEN). Serve binds loopback only and always requires a token.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN", "");
|
|
451
454
|
return lines.join("\n");
|
|
452
455
|
}
|
|
453
456
|
/** Turn argv (without the op) into dispatch args, driven by PARAMS. */
|
package/dist/state.d.ts
CHANGED
|
@@ -19,9 +19,10 @@ export type SignalChannel = {
|
|
|
19
19
|
last: string | null;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* Written
|
|
23
|
-
* been answered. Its presence is what lets later runs try the fast
|
|
24
|
-
* every cold `zswarm status` paying for a pipe that was never
|
|
22
|
+
* Written by `bus --install` per Zellij session, after the plugin's permission
|
|
23
|
+
* prompt has been answered. Its presence is what lets later runs try the fast
|
|
24
|
+
* path without every cold `zswarm status` paying for a pipe that was never
|
|
25
|
+
* going to answer.
|
|
25
26
|
*/
|
|
26
27
|
export type BusMarkerRecord = {
|
|
27
28
|
plugin: string;
|
|
@@ -44,9 +45,9 @@ export declare function createStateStore(options?: StateStoreOptions): {
|
|
|
44
45
|
readCursor: (key: string) => string | null;
|
|
45
46
|
writeCursor: (key: string, text: string) => void;
|
|
46
47
|
clearCursor: (key: string) => void;
|
|
47
|
-
readBus: () => BusMarkerRecord | null;
|
|
48
|
-
writeBus: (marker: BusMarkerRecord) => void;
|
|
49
|
-
clearBus: () => void;
|
|
48
|
+
readBus: (session: string) => BusMarkerRecord | null;
|
|
49
|
+
writeBus: (session: string, marker: BusMarkerRecord) => void;
|
|
50
|
+
clearBus: (session?: string) => void;
|
|
50
51
|
reset: () => void;
|
|
51
52
|
};
|
|
52
53
|
export type StateStore = ReturnType<typeof createStateStore>;
|