@zswarm/core 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/exec.d.ts CHANGED
@@ -7,6 +7,8 @@ export type ExecResult = {
7
7
  export type ExecOptions = {
8
8
  timeoutMs: number;
9
9
  cwd?: string;
10
+ /** Overlay on the runner's env (e.g. GIT_INDEX_FILE for a scratch index). */
11
+ env?: NodeJS.ProcessEnv;
10
12
  /**
11
13
  * Stop as soon as the accumulated stdout satisfies this, instead of waiting
12
14
  * for the process to exit. `zellij pipe` answers in milliseconds but stays
package/dist/exec.js CHANGED
@@ -70,10 +70,12 @@ export function createSshExec(target, env) {
70
70
  if (requested.toLowerCase() !== "auto") {
71
71
  return { tmp: requested, socketDir: "" };
72
72
  }
73
- if (cached !== undefined)
74
- return cached ?? undefined;
75
- cached = (await discoverRemoteIpc(runner, target, timeoutMs)) ?? null;
76
- return cached ?? undefined;
73
+ if (cached)
74
+ return cached;
75
+ const dirs = await discoverRemoteIpc(runner, target, timeoutMs);
76
+ if (dirs)
77
+ cached = dirs;
78
+ return dirs;
77
79
  }
78
80
  return async (args, options) => {
79
81
  const ipc = await resolveIpc(options.timeoutMs);
@@ -85,7 +87,7 @@ export function createSshExec(target, env) {
85
87
  function runUntil(binPath, args, env, options) {
86
88
  return new Promise((resolve) => {
87
89
  const child = spawn(binPath, args, {
88
- env,
90
+ env: options.env ? { ...env, ...options.env } : env,
89
91
  cwd: options.cwd,
90
92
  windowsHide: true,
91
93
  stdio: ["ignore", "pipe", "pipe"],
@@ -136,7 +138,7 @@ function runToExit(binPath, args, env, options) {
136
138
  cwd: options.cwd,
137
139
  maxBuffer: 8 * 1024 * 1024,
138
140
  windowsHide: true,
139
- env,
141
+ env: options.env ? { ...env, ...options.env } : env,
140
142
  }, (error, stdout, stderr) => {
141
143
  const failure = error;
142
144
  if (failure && typeof failure.code === "string") {
package/dist/git.js CHANGED
@@ -1,4 +1,6 @@
1
- import { basename, dirname, join } from "node:path";
1
+ import { copyFileSync, rmSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { basename, dirname, isAbsolute, join } from "node:path";
2
4
  import { ZellijError } from "./errors.js";
3
5
  import { createExec, NOT_FOUND_EXIT } from "./exec.js";
4
6
  const DEFAULT_TIMEOUT_MS = 20_000;
@@ -77,8 +79,8 @@ export function createGitClient(options = {}) {
77
79
  const gitPath = options.gitPath ?? env.ZSWARM_GIT_BIN?.trim() ?? "git";
78
80
  const exec = options.exec ?? createExec(gitPath, env);
79
81
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
80
- async function run(args, cwd, label) {
81
- const result = await exec(args, { timeoutMs, cwd });
82
+ async function run(args, cwd, label, extraEnv) {
83
+ const result = await exec(args, { timeoutMs, cwd, env: extraEnv });
82
84
  if (result.code === NOT_FOUND_EXIT) {
83
85
  throw new ZellijError("git_missing", `git not found (${gitPath}); install git or set ZSWARM_GIT_BIN`);
84
86
  }
@@ -136,32 +138,54 @@ export function createGitClient(options = {}) {
136
138
  return result.stdout.trim().length > 0;
137
139
  }
138
140
  /**
139
- * Stage untracked paths as intent-to-add so they appear in `git diff`
140
- * (without copying blob contents into the object database).
141
+ * Stage untracked paths as intent-to-add in a scratch index so they appear
142
+ * in `git diff` without mutating the real `.git/index`.
141
143
  */
142
- async function intentAddUntracked(path) {
143
- await run(["add", "-A", "--intent-to-add"], path, "git add --intent-to-add");
144
+ async function withScratchIndex(path, fn) {
145
+ const located = await run(["rev-parse", "--git-path", "index"], path, "git rev-parse --git-path index");
146
+ const indexRel = located.stdout.trim();
147
+ const indexAbs = isAbsolute(indexRel) ? indexRel : join(path, indexRel);
148
+ const scratch = `${indexAbs}.zswarm-${process.pid}-${randomBytes(4).toString("hex")}`;
149
+ try {
150
+ copyFileSync(indexAbs, scratch);
151
+ }
152
+ catch {
153
+ // No index yet (empty repo / new worktree).
154
+ }
155
+ try {
156
+ return await fn({ GIT_INDEX_FILE: scratch });
157
+ }
158
+ finally {
159
+ rmSync(scratch, { force: true });
160
+ }
144
161
  }
145
- /** `git diff --stat HEAD`, including untracked via `--intent-to-add`. */
162
+ async function intentAddUntracked(path, indexEnv) {
163
+ await run(["add", "-A", "--intent-to-add"], path, "git add --intent-to-add", indexEnv);
164
+ }
165
+ /** `git diff --stat HEAD`, including untracked via a scratch intent-to-add. */
146
166
  async function diffStat(path) {
147
- await intentAddUntracked(path);
148
- const result = await run(["diff", "--stat", "HEAD"], path, "git diff --stat");
149
- return result.stdout;
167
+ return withScratchIndex(path, async (indexEnv) => {
168
+ await intentAddUntracked(path, indexEnv);
169
+ const result = await run(["diff", "--stat", "HEAD"], path, "git diff --stat", indexEnv);
170
+ return result.stdout;
171
+ });
150
172
  }
151
173
  /**
152
- * `git diff HEAD`, including untracked via `--intent-to-add`.
174
+ * `git diff HEAD`, including untracked via a scratch intent-to-add.
153
175
  * When `maxChars > 0` and the patch is longer, keep the head and set
154
176
  * `truncated`. `chars` is always the full patch length before truncating.
155
177
  */
156
178
  async function diffPatch(path, maxChars) {
157
- await intentAddUntracked(path);
158
- const result = await run(["diff", "HEAD"], path, "git diff");
159
- const full = result.stdout;
160
- const chars = full.length;
161
- if (maxChars <= 0 || chars <= maxChars) {
162
- return { patch: full, truncated: false, chars };
163
- }
164
- return { patch: full.slice(0, maxChars), truncated: true, chars };
179
+ return withScratchIndex(path, async (indexEnv) => {
180
+ await intentAddUntracked(path, indexEnv);
181
+ const result = await run(["diff", "HEAD"], path, "git diff", indexEnv);
182
+ const full = result.stdout;
183
+ const chars = full.length;
184
+ if (maxChars <= 0 || chars <= maxChars) {
185
+ return { patch: full, truncated: false, chars };
186
+ }
187
+ return { patch: full.slice(0, maxChars), truncated: true, chars };
188
+ });
165
189
  }
166
190
  /** `git add -A` then `git commit -m`; returns the new short HEAD sha. */
167
191
  async function commitAll(path, message) {
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy,
14
14
  export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type SshTarget, } from "./exec.js";
15
15
  export { resolveSshTarget } from "./zellij/binary.js";
16
16
  export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
17
- export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
17
+ export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
18
18
  export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
19
19
  export { selectTargets } from "./ops/broadcast.js";
20
20
  export { diffScreens, cursorKey } from "./ops/tail.js";
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./po
14
14
  export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
15
15
  export { resolveSshTarget } from "./zellij/binary.js";
16
16
  export { applyIpcTmpEnv, cmdQuote, concreteTmp, encodePowerShellCommand, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, pickIpcTmp, socketDirFromServerPath, tmpFromServerPath, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
17
- export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, parseListenAddress, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
17
+ export { callServe, DEFAULT_SERVE_LISTEN, installServeLogon, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
18
18
  export { parseTabList, resolveTab } from "./zellij/tabs.js";
19
19
  export { selectTargets } from "./ops/broadcast.js";
20
20
  export { diffScreens, cursorKey } from "./ops/tail.js";
@@ -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";
@@ -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, paneViewBus, paneViewFull, paneViewSlim, truncateDumpText, } from "./util.js";
20
+ import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, 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";
@@ -29,6 +29,30 @@ function waitMode(args) {
29
29
  }
30
30
  return typeof args.match === "string" && args.match ? "match" : "idle";
31
31
  }
32
+ const SSH_GIT_OPS = new Set([
33
+ "worktrees",
34
+ "unworktree",
35
+ "diff",
36
+ "checkpoint",
37
+ ]);
38
+ const SSH_LOCAL_BARRIER_OPS = new Set(["signal", "signals", "await"]);
39
+ /**
40
+ * SSH only forwards Zellij. Git worktrees, diffs, and checkpoints stay on this
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.
43
+ */
44
+ function assertSshGitAllowed(env, op, args) {
45
+ if (!env.ZSWARM_SSH?.trim() || env.ZSWARM_SERVE?.trim())
46
+ return;
47
+ const worktreeSpawn = op === "spawn" && optionalString(args.worktree);
48
+ if (!worktreeSpawn && !SSH_GIT_OPS.has(op) && !SSH_LOCAL_BARRIER_OPS.has(op)) {
49
+ return;
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`);
55
+ }
32
56
  async function resolveTarget(client, args, state, clock, env, policy, op) {
33
57
  const to = String(args.to ?? "").trim();
34
58
  if (!to)
@@ -76,6 +100,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
76
100
  try {
77
101
  // Policy gates the op before anything touches the session.
78
102
  assertOpAllowed(policy, op);
103
+ assertSshGitAllowed(env, op, args);
79
104
  if (op === "serve") {
80
105
  if (isTrue(args.clear)) {
81
106
  const cleared = await uninstallServeLogon({
@@ -94,7 +119,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
94
119
  // An injected client is a unit-test (or in-process) Zellij; do not skip it
95
120
  // just because the host env has ZSWARM_SERVE set.
96
121
  if (!injected && env.ZSWARM_SERVE?.trim()) {
97
- return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args));
122
+ return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
98
123
  }
99
124
  const client = injected ?? createZellijClient({ env: deps.env });
100
125
  switch (op) {
@@ -127,7 +152,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
127
152
  const body = String(args.body ?? args.text ?? "");
128
153
  if (!body.trim())
129
154
  throw new ZellijError("missing_body", "body required");
130
- 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);
131
156
  assertWritable(client, pane, args, "send");
132
157
  // A pane that dropped back to a shell will *run* the message. There is
133
158
  // no reliable way to tell an agent from a prompt, so the caller names
@@ -137,7 +162,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
137
162
  const screen = await client.dumpPane({ session, paneId: pane.id });
138
163
  assertPaneExpects(screen.text, expect, pane.id);
139
164
  }
140
- 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, {
141
170
  session,
142
171
  pane,
143
172
  body,
@@ -152,7 +181,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
152
181
  delivery: result.delivery,
153
182
  session,
154
183
  to: result.to,
155
- from: (typeof args.from === "string" && args.from.trim()) || "swarm",
184
+ from: labeled.from,
156
185
  submitted: result.submitted,
157
186
  };
158
187
  if (verbose)
@@ -160,7 +189,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
160
189
  return { ok: true, data };
161
190
  }
162
191
  case "broadcast":
163
- return await broadcast(client, state(), args, clock, policy);
192
+ return await broadcast(client, state(), args, clock, policy, env);
164
193
  case "keys":
165
194
  case "interrupt": {
166
195
  const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
@@ -307,15 +336,15 @@ export async function dispatchZswarm(args, injected, deps = {}) {
307
336
  case "log":
308
337
  return readDeliveryLog(state(), args);
309
338
  case "rename":
310
- return await renameTarget(client, args);
339
+ return await renameTarget(client, args, policy);
311
340
  case "focus":
312
- return await focusTarget(client, args);
341
+ return await focusTarget(client, args, policy);
313
342
  case "tabs":
314
343
  return await listTabsOp(client, args);
315
344
  case "layout":
316
345
  return await dumpLayoutOp(client, args);
317
346
  case "stack":
318
- return await stackTargets(client, args);
347
+ return await stackTargets(client, args, policy);
319
348
  case "diff":
320
349
  return await peerDiff(git(), args);
321
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 } };
@@ -1,17 +1,33 @@
1
1
  import type { OpsResult } from "./types.js";
2
2
  export declare const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
3
3
  export declare const SERVE_TASK_NAME = "zswarm-serve";
4
+ /** One JSONL request; a client that never sends newline cannot grow forever. */
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;
10
+ /** Wait/await allow 15 minutes; this is that plus slack. */
11
+ export declare const SERVE_CALL_TIMEOUT_CAP_MS: number;
12
+ export declare function isLoopbackHost(host: string): boolean;
13
+ export declare function formatListenLabel(host: string, port: number): string;
4
14
  export declare function parseListenAddress(raw: string | undefined): {
5
15
  host: string;
6
16
  port: number;
7
17
  label: string;
8
18
  };
9
- /** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
19
+ /** Client-side wait: the op's own timeout plus slack. */
10
20
  export declare function serveCallTimeout(args: Record<string, unknown>): number;
11
21
  /** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
12
22
  export declare function serveChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
13
23
  export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult>;
14
- export declare function startServe(listen: string | undefined, dispatch: ServeDispatch): Promise<{
24
+ export type StartServeOptions = {
25
+ token?: string;
26
+ maxRequestBytes?: number;
27
+ idleTimeoutMs?: number;
28
+ maxConnections?: number;
29
+ };
30
+ export declare function startServe(listen: string | undefined, dispatch: ServeDispatch, options?: StartServeOptions): Promise<{
15
31
  label: string;
16
32
  close: () => Promise<void>;
17
33
  }>;
@@ -19,13 +35,15 @@ export declare function parseServeTarget(raw: string): {
19
35
  host: string;
20
36
  port: number;
21
37
  };
22
- export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number): Promise<OpsResult>;
23
- export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
38
+ export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
39
+ export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
24
40
  export declare function installServeLogon(input: {
25
41
  listen?: string;
26
42
  execPath?: string;
27
43
  scriptPath?: string;
28
44
  platform?: NodeJS.Platform;
45
+ token?: string;
46
+ env?: NodeJS.ProcessEnv;
29
47
  }): Promise<{
30
48
  task: string;
31
49
  listen: string;