@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.
@@ -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>;
@@ -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 { targets: selected, skipped } = selectTargets(client, panes, args);
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, args, {
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: (typeof args.from === "string" && args.from.trim()) || "swarm",
111
+ from: labeled.from,
108
112
  delivered: delivered.map((r) => r.to),
109
113
  failed: results.filter((r) => !r.ok),
110
114
  skipped,
@@ -14,7 +14,37 @@ export type DeliveryResult = {
14
14
  };
15
15
  };
16
16
  export type SubmitMode = "auto" | "double-enter" | "none";
17
- export declare function senderLabel(args: Record<string, unknown>): string;
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
@@ -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
- export function senderLabel(args) {
6
- return (typeof args.from === "string" && args.from.trim()) || "swarm";
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);
@@ -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
- 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();
@@ -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 result = await deliverTo(client, state(), args, {
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: (typeof args.from === "string" && args.from.trim()) || "swarm",
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":
@@ -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,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 && !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,24 @@ 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 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 withSignalsLock(fn) {
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, SIGNALS_LOCK);
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
- rmSync(lockPath, { force: true });
158
+ unlinkLock(lockPath);
93
159
  }
94
160
  }
95
161
  catch (err) {
96
162
  const code = err.code;
97
- if (code !== "EEXIST")
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 ${SIGNALS_LOCK}`);
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
- const all = readJson(CURSORS_FILE, {});
140
- all[key] = text;
141
- writeJson(CURSORS_FILE, all);
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
- const all = readJson(CURSORS_FILE, {});
145
- delete all[key];
146
- writeJson(CURSORS_FILE, all);
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);
@@ -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 on whitespace; `BatchMode` keeps it non-interactive.
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;
@@ -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 on whitespace; `BatchMode` keeps it non-interactive.
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 ?? "").trim().split(/\s+/).filter(Boolean);
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
  }
@@ -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 run(["list-sessions", "--short", "--no-formatting"], "zellij list-sessions");
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) {
@@ -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. */
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {