@zswarm/core 0.1.2 → 0.1.3

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";
@@ -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,24 @@ 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
+ /**
39
+ * SSH only forwards Zellij. Git worktrees, diffs, and checkpoints stay on this
40
+ * machine, so mixing them with a remote pane cwd is how you delete the wrong tree.
41
+ */
42
+ function assertSshGitAllowed(env, op, args) {
43
+ if (!env.ZSWARM_SSH?.trim() || env.ZSWARM_SERVE?.trim())
44
+ return;
45
+ const worktreeSpawn = op === "spawn" && optionalString(args.worktree);
46
+ if (!worktreeSpawn && !SSH_GIT_OPS.has(op))
47
+ 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`);
49
+ }
32
50
  async function resolveTarget(client, args, state, clock, env, policy, op) {
33
51
  const to = String(args.to ?? "").trim();
34
52
  if (!to)
@@ -76,6 +94,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
76
94
  try {
77
95
  // Policy gates the op before anything touches the session.
78
96
  assertOpAllowed(policy, op);
97
+ assertSshGitAllowed(env, op, args);
79
98
  if (op === "serve") {
80
99
  if (isTrue(args.clear)) {
81
100
  const cleared = await uninstallServeLogon({
@@ -94,7 +113,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
94
113
  // An injected client is a unit-test (or in-process) Zellij; do not skip it
95
114
  // just because the host env has ZSWARM_SERVE set.
96
115
  if (!injected && env.ZSWARM_SERVE?.trim()) {
97
- return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args));
116
+ return await callServe(env.ZSWARM_SERVE.trim(), args, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
98
117
  }
99
118
  const client = injected ?? createZellijClient({ env: deps.env });
100
119
  switch (op) {
@@ -1,17 +1,27 @@
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
+ /** Wait/await allow 15 minutes; this is that plus slack. */
7
+ export declare const SERVE_CALL_TIMEOUT_CAP_MS: number;
8
+ export declare function isLoopbackHost(host: string): boolean;
9
+ export declare function formatListenLabel(host: string, port: number): string;
4
10
  export declare function parseListenAddress(raw: string | undefined): {
5
11
  host: string;
6
12
  port: number;
7
13
  label: string;
8
14
  };
9
- /** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
15
+ /** Client-side wait: the op's own timeout plus slack. */
10
16
  export declare function serveCallTimeout(args: Record<string, unknown>): number;
11
17
  /** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
12
18
  export declare function serveChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
13
19
  export type ServeDispatch = (args: Record<string, unknown>) => Promise<OpsResult>;
14
- export declare function startServe(listen: string | undefined, dispatch: ServeDispatch): Promise<{
20
+ export type StartServeOptions = {
21
+ token?: string;
22
+ maxRequestBytes?: number;
23
+ };
24
+ export declare function startServe(listen: string | undefined, dispatch: ServeDispatch, options?: StartServeOptions): Promise<{
15
25
  label: string;
16
26
  close: () => Promise<void>;
17
27
  }>;
@@ -19,7 +29,7 @@ export declare function parseServeTarget(raw: string): {
19
29
  host: string;
20
30
  port: number;
21
31
  };
22
- export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number): Promise<OpsResult>;
32
+ export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
23
33
  export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string): string;
24
34
  export declare function installServeLogon(input: {
25
35
  listen?: string;
package/dist/ops/serve.js CHANGED
@@ -4,27 +4,56 @@ import { ZellijError } from "../errors.js";
4
4
  import { encodePowerShellCommand } from "../zellij/ipc.js";
5
5
  export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
6
6
  export const SERVE_TASK_NAME = "zswarm-serve";
7
+ /** One JSONL request; a client that never sends newline cannot grow forever. */
8
+ export const SERVE_MAX_REQUEST_BYTES = 1024 * 1024;
9
+ /** Wait/await allow 15 minutes; this is that plus slack. */
10
+ export const SERVE_CALL_TIMEOUT_CAP_MS = 16 * 60_000;
11
+ const SERVE_TOKEN_FIELD = "serveToken";
12
+ export function isLoopbackHost(host) {
13
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
14
+ return (h === "127.0.0.1" ||
15
+ h === "localhost" ||
16
+ h === "::1" ||
17
+ h === "0:0:0:0:0:0:0:1");
18
+ }
19
+ export function formatListenLabel(host, port) {
20
+ return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
21
+ }
7
22
  export function parseListenAddress(raw) {
8
23
  const text = (raw ?? DEFAULT_SERVE_LISTEN).trim() || DEFAULT_SERVE_LISTEN;
9
24
  const stripped = text.replace(/^tcp:\/\//i, "");
10
- const hostPort = stripped.includes("]")
11
- ? stripped
12
- : stripped.includes(":")
25
+ let host;
26
+ let port;
27
+ if (stripped.startsWith("[")) {
28
+ const end = stripped.indexOf("]");
29
+ if (end === -1) {
30
+ throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
31
+ }
32
+ host = stripped.slice(1, end);
33
+ const rest = stripped.slice(end + 1);
34
+ if (!rest.startsWith(":")) {
35
+ throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
36
+ }
37
+ port = Number(rest.slice(1));
38
+ }
39
+ else {
40
+ const hostPort = stripped.includes(":")
13
41
  ? stripped
14
42
  : `127.0.0.1:${stripped}`;
15
- const colon = hostPort.lastIndexOf(":");
16
- const host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
17
- const port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
18
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
43
+ const colon = hostPort.lastIndexOf(":");
44
+ host = colon === -1 ? "127.0.0.1" : hostPort.slice(0, colon) || "127.0.0.1";
45
+ port = Number(colon === -1 ? hostPort : hostPort.slice(colon + 1));
46
+ }
47
+ if (!host || !Number.isInteger(port) || port < 0 || port > 65535) {
19
48
  throw new ZellijError("bad_arg", `listen address is not host:port (${text})`);
20
49
  }
21
- return { host, port, label: `${host}:${port}` };
50
+ return { host, port, label: formatListenLabel(host, port) };
22
51
  }
23
- /** Client-side wait: the op's own timeout plus slack, capped at ten minutes. */
52
+ /** Client-side wait: the op's own timeout plus slack. */
24
53
  export function serveCallTimeout(args) {
25
54
  const requested = Number(args.timeoutMs);
26
55
  const base = Number.isFinite(requested) && requested > 0 ? requested : 60_000;
27
- return Math.min(Math.max(base + 5_000, 15_000), 10 * 60_000);
56
+ return Math.min(Math.max(base + 5_000, 15_000), SERVE_CALL_TIMEOUT_CAP_MS);
28
57
  }
29
58
  /** Env for a serve worker: talk to the local Zellij, never loop back into serve/ssh. */
30
59
  export function serveChildEnv(env) {
@@ -33,14 +62,47 @@ export function serveChildEnv(env) {
33
62
  delete out.ZSWARM_SSH;
34
63
  return out;
35
64
  }
36
- export function startServe(listen, dispatch) {
65
+ function unauthorized() {
66
+ return {
67
+ ok: false,
68
+ error: {
69
+ code: "serve_unauthorized",
70
+ message: "zswarm serve rejected the request (missing or wrong ZSWARM_SERVE_TOKEN)",
71
+ },
72
+ };
73
+ }
74
+ function takeServeToken(args) {
75
+ const token = typeof args[SERVE_TOKEN_FIELD] === "string"
76
+ ? args[SERVE_TOKEN_FIELD]
77
+ : undefined;
78
+ const request = { ...args };
79
+ delete request[SERVE_TOKEN_FIELD];
80
+ return { token, request };
81
+ }
82
+ export function startServe(listen, dispatch, options = {}) {
37
83
  const { host, port } = parseListenAddress(listen);
84
+ 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`));
87
+ }
88
+ const maxRequestBytes = options.maxRequestBytes ?? SERVE_MAX_REQUEST_BYTES;
38
89
  return new Promise((resolve, reject) => {
39
90
  const server = createServer((socket) => {
40
91
  let buf = "";
41
92
  socket.setEncoding("utf8");
42
93
  socket.on("data", (chunk) => {
43
94
  buf += chunk;
95
+ if (buf.length > maxRequestBytes) {
96
+ socket.write(`${JSON.stringify({
97
+ ok: false,
98
+ error: {
99
+ code: "bad_arg",
100
+ message: `serve request exceeded ${maxRequestBytes} bytes`,
101
+ },
102
+ })}\n`);
103
+ socket.destroy();
104
+ return;
105
+ }
44
106
  void drain();
45
107
  });
46
108
  async function drain() {
@@ -51,8 +113,14 @@ export function startServe(listen, dispatch) {
51
113
  if (line) {
52
114
  let result;
53
115
  try {
54
- const args = JSON.parse(line);
55
- result = await dispatch(args);
116
+ const parsed = JSON.parse(line);
117
+ const taken = takeServeToken(parsed);
118
+ if (token && taken.token !== token) {
119
+ result = unauthorized();
120
+ }
121
+ else {
122
+ result = await dispatch(taken.request);
123
+ }
56
124
  }
57
125
  catch (err) {
58
126
  const message = err instanceof Error ? err.message : String(err);
@@ -69,7 +137,7 @@ export function startServe(listen, dispatch) {
69
137
  const addr = server.address();
70
138
  const actualPort = typeof addr === "object" && addr ? addr.port : port;
71
139
  resolve({
72
- label: `${host}:${actualPort}`,
140
+ label: formatListenLabel(host, actualPort),
73
141
  close: () => new Promise((done, fail) => {
74
142
  server.close((err) => (err ? fail(err) : done()));
75
143
  }),
@@ -80,8 +148,11 @@ export function startServe(listen, dispatch) {
80
148
  export function parseServeTarget(raw) {
81
149
  return parseListenAddress(raw);
82
150
  }
83
- export function callServe(target, args, timeoutMs = 15_000) {
151
+ export function callServe(target, args, timeoutMs = 15_000, token) {
84
152
  const { host, port } = parseServeTarget(target);
153
+ const payload = token && token.trim()
154
+ ? { ...args, [SERVE_TOKEN_FIELD]: token.trim() }
155
+ : args;
85
156
  return new Promise((resolve) => {
86
157
  const socket = connect({ host, port });
87
158
  let buf = "";
@@ -130,7 +201,7 @@ export function callServe(target, args, timeoutMs = 15_000) {
130
201
  }
131
202
  });
132
203
  socket.on("connect", () => {
133
- socket.write(`${JSON.stringify(args)}\n`);
204
+ socket.write(`${JSON.stringify(payload)}\n`);
134
205
  });
135
206
  });
136
207
  }
@@ -191,7 +262,19 @@ export async function uninstallServeLogon(input = {}) {
191
262
  if ((input.platform ?? process.platform) !== "win32") {
192
263
  throw new ZellijError("usage", "serve --clear is Windows-only");
193
264
  }
194
- const script = `Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false -ErrorAction SilentlyContinue`;
195
- await runPowerShell(script);
265
+ const script = [
266
+ "$ProgressPreference = 'SilentlyContinue'",
267
+ "try {",
268
+ ` Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false`,
269
+ "} catch {",
270
+ " if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { exit 0 }",
271
+ " [Console]::Error.Write(($_ | Out-String).Trim())",
272
+ " exit 1",
273
+ "}",
274
+ ].join("\n");
275
+ const result = await runPowerShell(script);
276
+ if (result.code !== 0) {
277
+ throw new ZellijError("zellij_failed", `serve --clear failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
278
+ }
196
279
  return { task: SERVE_TASK_NAME, cleared: true };
197
280
  }
@@ -120,8 +120,15 @@ export async function removePeerWorktree(git, client, args, env = process.env) {
120
120
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
121
121
  occupants = panesIn(await client.listPanes(session), target.path);
122
122
  }
123
- catch {
124
- // No live session means nobody is working in it.
123
+ catch (err) {
124
+ // No live session means nobody is working in it. Any other failure must
125
+ // not look like "empty" — that is how a clean busy worktree gets deleted.
126
+ if (err instanceof ZellijError && err.code === "zellij_no_session") {
127
+ occupants = [];
128
+ }
129
+ else {
130
+ throw err;
131
+ }
125
132
  }
126
133
  if (occupants.length > 0 && !force) {
127
134
  throw new ZellijError("worktree_busy", `${occupants.join(", ")} still working in ${target.path}; close the pane or pass force=true`);
package/dist/policy.js CHANGED
@@ -7,6 +7,12 @@ const WRITE_OPS = new Set([
7
7
  "spawn",
8
8
  "close",
9
9
  "unworktree",
10
+ "checkpoint",
11
+ "signal",
12
+ "rename",
13
+ "focus",
14
+ "stack",
15
+ "serve",
10
16
  ]);
11
17
  /** Explicit disable tokens for ZSWARM_ALLOW_* switches. */
12
18
  function isDisabled(raw) {
package/dist/schema.js CHANGED
@@ -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.", "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", "");
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", "");
451
451
  return lines.join("\n");
452
452
  }
453
453
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
@@ -465,10 +465,22 @@ function extractOp(argv) {
465
465
  return { op: first, rest: argv.slice(1) };
466
466
  }
467
467
  const names = OP_NAMES;
468
- const at = argv.findIndex((token) => names.includes(token));
469
- if (at === -1)
470
- return { op: first ?? "", rest: argv.slice(1) };
471
- return { op: argv[at], rest: [...argv.slice(0, at), ...argv.slice(at + 1)] };
468
+ for (let i = 0; i < argv.length; i++) {
469
+ const token = argv[i];
470
+ if (token.startsWith("-")) {
471
+ const param = FLAG_INDEX.get(token);
472
+ if (param && param.type !== "boolean")
473
+ i += 1;
474
+ continue;
475
+ }
476
+ if (names.includes(token)) {
477
+ return {
478
+ op: token,
479
+ rest: [...argv.slice(0, i), ...argv.slice(i + 1)],
480
+ };
481
+ }
482
+ }
483
+ return { op: first ?? "", rest: argv.slice(1) };
472
484
  }
473
485
  export function parseCliArgv(argv) {
474
486
  const { op, rest } = extractOp(argv);
package/dist/state.js CHANGED
@@ -1,12 +1,17 @@
1
- import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, 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
+ const SIGNALS_LOCK = "signals.lock";
6
7
  const CURSORS_FILE = "cursors.json";
7
8
  const BUS_FILE = "bus.json";
8
9
  /** Keeps the log bounded without needing a rotation daemon. */
9
10
  const LOG_TAIL_BYTES = 512 * 1024;
11
+ const LOCK_WAIT_MS = 5_000;
12
+ function sleepSync(ms) {
13
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
+ }
10
15
  export function defaultStateDir(env = process.env) {
11
16
  const explicit = env.ZSWARM_STATE_DIR?.trim();
12
17
  if (explicit)
@@ -72,29 +77,59 @@ export function createStateStore(options = {}) {
72
77
  }
73
78
  return out;
74
79
  }
80
+ function withSignalsLock(fn) {
81
+ ensureDir();
82
+ const lockPath = join(dir, SIGNALS_LOCK);
83
+ const deadline = Date.now() + LOCK_WAIT_MS;
84
+ while (true) {
85
+ try {
86
+ const fd = openSync(lockPath, "wx");
87
+ try {
88
+ return fn();
89
+ }
90
+ finally {
91
+ closeSync(fd);
92
+ rmSync(lockPath, { force: true });
93
+ }
94
+ }
95
+ catch (err) {
96
+ const code = err.code;
97
+ if (code !== "EEXIST")
98
+ throw err;
99
+ if (Date.now() >= deadline) {
100
+ throw new Error(`timed out waiting for ${SIGNALS_LOCK}`);
101
+ }
102
+ sleepSync(10);
103
+ }
104
+ }
105
+ }
75
106
  function readSignals() {
76
107
  return readJson(SIGNALS_FILE, {});
77
108
  }
78
109
  function postSignal(channel, payload, at) {
79
- const all = readSignals();
80
- const prev = all[channel];
81
- const next = {
82
- count: (prev?.count ?? 0) + 1,
83
- at,
84
- last: payload,
85
- };
86
- all[channel] = next;
87
- writeJson(SIGNALS_FILE, all);
88
- return next;
110
+ return withSignalsLock(() => {
111
+ const all = readSignals();
112
+ const prev = all[channel];
113
+ const next = {
114
+ count: (prev?.count ?? 0) + 1,
115
+ at,
116
+ last: payload,
117
+ };
118
+ all[channel] = next;
119
+ writeJson(SIGNALS_FILE, all);
120
+ return next;
121
+ });
89
122
  }
90
123
  function clearSignal(channel) {
91
- if (channel === null) {
92
- writeJson(SIGNALS_FILE, {});
93
- return;
94
- }
95
- const all = readSignals();
96
- delete all[channel];
97
- writeJson(SIGNALS_FILE, all);
124
+ withSignalsLock(() => {
125
+ if (channel === null) {
126
+ writeJson(SIGNALS_FILE, {});
127
+ return;
128
+ }
129
+ const all = readSignals();
130
+ delete all[channel];
131
+ writeJson(SIGNALS_FILE, all);
132
+ });
98
133
  }
99
134
  function readCursor(key) {
100
135
  const all = readJson(CURSORS_FILE, {});
@@ -7,7 +7,7 @@
7
7
  */
8
8
  /** `…/zellij/contract_version_N/…` or Unix `…/zellij-<uid>/contract_version_N/…`. */
9
9
  const ZELLIJ_DIR = /[\\/](zellij(?:-\d+)?)[\\/]contract_version_[^\\/]+[\\/]/i;
10
- const SERVER_FLAG = /(?:^|\s)--server(?:\s+|=)(\S+)/;
10
+ const SERVER_FLAG = /(?:^|\s)--server(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/;
11
11
  /** Drive letter plus rest, or a UNC / posix path. */
12
12
  export function looksLikeWindowsPath(path) {
13
13
  return /^[A-Za-z]:[\\/]/.test(path) || path.includes("\\");
@@ -50,8 +50,9 @@ export function parseZellijServerPaths(commandLines) {
50
50
  const found = [];
51
51
  for (const line of commandLines.split(/\r?\n/)) {
52
52
  const match = SERVER_FLAG.exec(line);
53
- if (match?.[1])
54
- found.push(stripQuotes(match[1]));
53
+ const captured = match?.[1] || match?.[2] || match?.[3];
54
+ if (captured)
55
+ found.push(stripQuotes(captured));
55
56
  }
56
57
  return found;
57
58
  }
@@ -104,9 +105,12 @@ export function inferRemoteShell(input) {
104
105
  export function cmdQuote(arg) {
105
106
  if (arg === "")
106
107
  return '""';
107
- if (/^[A-Za-z0-9_@%+=:,.\\/-]+$/.test(arg))
108
- return arg;
109
- return `"${arg.replace(/"/g, '""')}"`;
108
+ // cmd.exe expands %VAR% even inside quotes; double percents to keep literals.
109
+ const escaped = arg.replace(/%/g, "%%");
110
+ if (/^[A-Za-z0-9_@+=:,.\\/-]+$/.test(escaped.replace(/%%/g, ""))) {
111
+ return escaped;
112
+ }
113
+ return `"${escaped.replace(/"/g, '""')}"`;
110
114
  }
111
115
  function defaultSocketDir(tmp, shell) {
112
116
  if (shell !== "cmd")
@@ -161,11 +165,13 @@ export function windowsInteractiveRemote(innerCmd, timeoutMs) {
161
165
  "$out = Join-Path $dir \"zswarm-$id.out\"",
162
166
  "$err = Join-Path $dir \"zswarm-$id.err\"",
163
167
  "$code = Join-Path $dir \"zswarm-$id.code\"",
164
- "@(",
168
+ "$lines = @(",
165
169
  " '@echo off'",
170
+ " 'chcp 65001 >nul'",
166
171
  " \"$inner > `\"$out`\" 2> `\"$err`\"\"",
167
172
  " \"echo %ERRORLEVEL% > `\"$code`\"\"",
168
- ") | Set-Content -Path $bat -Encoding ASCII",
173
+ ")",
174
+ "[IO.File]::WriteAllLines($bat, $lines, [Text.UTF8Encoding]::new($false))",
169
175
  "try {",
170
176
  " $create = & schtasks.exe /Create /TN $task /TR $bat /SC ONCE /ST 00:00 /IT /F /RL LIMITED 2>&1",
171
177
  " if ($LASTEXITCODE -ne 0) {",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "zSwarm Zellij client and shared ops dispatch",
6
6
  "exports": {
@@ -25,7 +25,7 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@zswarm/wasm": "0.1.0"
28
+ "@zswarm/wasm": "0.1.1"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json",