@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.
@@ -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
- throw new ZellijError("ssh_git_unsupported", `${op} uses local git; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
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((r) => setTimeout(r, ms))),
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 result = await deliverTo(client, state(), args, {
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: (typeof args.from === "string" && args.from.trim()) || "swarm",
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
- idleMs: Number(args.idleMs ?? 2000),
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":
@@ -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>): Promise<OpsResult>;
8
- export declare function focusTarget(client: ZellijClient, args: Record<string, unknown>): Promise<OpsResult>;
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>): Promise<OpsResult>;
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) => client.resolvePane(panes, key).id);
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 } };
@@ -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 serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
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 && !isLoopbackHost(host)) {
86
- return Promise.reject(new ZellijError("serve_auth", `listening on ${host} requires ZSWARM_SERVE_TOKEN; loopback can run without one`));
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
- let nl = buf.indexOf("\n");
110
- while (nl !== -1) {
111
- const line = buf.slice(0, nl).trim();
112
- buf = buf.slice(nl + 1);
113
- if (line) {
114
- let result;
115
- try {
116
- const parsed = JSON.parse(line);
117
- const taken = takeServeToken(parsed);
118
- if (token && taken.token !== token) {
119
- result = unauthorized();
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
- else {
122
- result = await dispatch(taken.request);
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
- catch (err) {
126
- const message = err instanceof Error ? err.message : String(err);
127
- result = { ok: false, error: { code: "bad_arg", message } };
128
- }
129
- socket.write(`${JSON.stringify(result)}\n`);
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 serveLogonCommand(execPath, scriptPath, listen) {
209
- return `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
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") {
@@ -19,6 +19,8 @@ export type DispatchDeps = {
19
19
  state?: StateStore;
20
20
  policy?: Policy;
21
21
  env?: NodeJS.ProcessEnv;
22
+ /** MCP cancellation; aborted waits stop instead of running to timeout. */
23
+ signal?: AbortSignal;
22
24
  };
23
25
  export type Clock = {
24
26
  now: () => number;
@@ -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;
@@ -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>): Promise<OpsResult>;
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
- const reply = await waitViaBus();
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: reinstall under a fresh key",
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, 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", "");
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 once by `bus --install`, after the plugin's permission prompt has
23
- * been answered. Its presence is what lets later runs try the fast path without
24
- * every cold `zswarm status` paying for a pipe that was never going to answer.
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>;