@zswarm/core 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -30,6 +30,14 @@ const result = await dispatchZswarm({ op: "list" });
30
30
  `{ ok, data }` or `{ ok: false, error }`, with routing `context` once the
31
31
  invocation's destination is known.
32
32
 
33
+ `ZSWARM_SERVE` / `--serve` may be `host:port`, `tcp://host:port`, or
34
+ `ssh://user@host[:sshPort]?servePort=9419`. The ssh URI opens a process-owned
35
+ SSH LocalForward and calls `probeServe` before ops (including before reuse);
36
+ pass a `serveTunnels` manager if the caller (MCP) should reuse it. Omitting
37
+ the SSH port leaves `Port` to OpenSSH instead of forcing 22. `tcp://` also
38
+ names a private Tailscale Serve frontend over a loopback backend; see
39
+ [Tailscale crew](../../docs/tailscale.md).
40
+
33
41
  Spawn lifecycle, body files, guarded keys, status tabs and routing context: [crew operations](../../docs/crew-operations.md).
34
42
 
35
43
  Ops, env, and the event bus: the [zswarm README](../../README.md).
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Every zSwarm failure carries a stable machine-readable code. */
2
2
  export declare class ZellijError extends Error {
3
3
  readonly code: string;
4
- constructor(code: string, message: string);
4
+ readonly details?: Record<string, unknown>;
5
+ constructor(code: string, message: string, details?: Record<string, unknown>);
5
6
  }
package/dist/errors.js CHANGED
@@ -1,9 +1,12 @@
1
1
  /** Every zSwarm failure carries a stable machine-readable code. */
2
2
  export class ZellijError extends Error {
3
3
  code;
4
- constructor(code, message) {
4
+ details;
5
+ constructor(code, message, details) {
5
6
  super(message);
6
7
  this.name = "ZellijError";
7
8
  this.code = code;
9
+ if (details && Object.keys(details).length > 0)
10
+ this.details = details;
8
11
  }
9
12
  }
package/dist/exec.d.ts CHANGED
@@ -1,8 +1,16 @@
1
- import { type RemoteShell } from "./zellij/ipc.js";
1
+ import { type IpcDirs, type RemoteShell } from "./zellij/ipc.js";
2
+ export type ExecSpawnFailure = {
3
+ /** Node could not start this process. Distinct from a remote 127. */
4
+ origin: "local";
5
+ errno: string;
6
+ bin: string;
7
+ };
2
8
  export type ExecResult = {
3
9
  code: number;
4
10
  stdout: string;
5
11
  stderr: string;
12
+ /** Present only when this host failed to spawn the binary. */
13
+ spawn?: ExecSpawnFailure;
6
14
  };
7
15
  export type ExecOptions = {
8
16
  timeoutMs: number;
@@ -49,10 +57,44 @@ export type IpcDiscoveryState = {
49
57
  };
50
58
  export type SshExecFn = ExecFn & {
51
59
  ipcState: IpcDiscoveryState;
60
+ /**
61
+ * Resolve `ZSWARM_TMP=auto` once under this caller's budget before parallel
62
+ * identity/capability probes. Independent of other callers' in-flight work.
63
+ */
64
+ prepareIpc: (timeoutMs: number, signal?: AbortSignal) => Promise<IpcDirs | undefined>;
52
65
  };
66
+ /** Positive `auto` IPC hits only; failures/cancels never enter this map. */
67
+ export declare const DEFAULT_IPC_DISCOVERY_TTL_MS = 15000;
68
+ export declare const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
69
+ /** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
70
+ export declare function ipcDiscoveryCacheKey(target: SshTarget, env?: NodeJS.ProcessEnv): string;
71
+ /**
72
+ * Probe list for `ZSWARM_TMP=auto`. Known Windows (cmd / interactive / .exe)
73
+ * skips the Unix `ps` fallback; explicit `sh` skips PowerShell. Unknown
74
+ * `zellij` still tries both — a Windows host with remoteBin=zellij looks Unix.
75
+ */
76
+ export declare function ipcDiscoveryProbes(target: SshTarget): string[];
77
+ export declare function resetIpcDiscoveryCache(): void;
78
+ /** Test helper: shrink the positive-hit TTL without exposing the map. */
79
+ export declare function setIpcDiscoveryTtlMs(ttlMs: number): void;
80
+ export declare function peekIpcDiscoveryCache(key: string): IpcDirs | undefined;
81
+ /**
82
+ * Canonical SSH + resolved IPC namespace. Listing caches should hash this
83
+ * (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
84
+ * socket context matches across `createSshExec` instances.
85
+ */
86
+ export declare function sshIpcContextKey(target: SshTarget, env?: NodeJS.ProcessEnv, ipc?: {
87
+ tmp?: string;
88
+ socketDir?: string;
89
+ } | null): string;
90
+ /** Test helper: shrink the positive-hit map so eviction is observable. */
91
+ export declare function setIpcDiscoveryCacheLimit(limit: number): void;
92
+ export declare function ipcDiscoveryCacheSize(): number;
53
93
  /**
54
94
  * Run the remote zellij over ssh. The whole invocation is quoted into a single
55
95
  * command string, because the remote login shell re-parses it.
56
96
  */
57
- export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv): SshExecFn;
97
+ export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv, options?: {
98
+ pinIpc?: boolean;
99
+ }): SshExecFn;
58
100
  export declare function createExec(binPath: string, env: NodeJS.ProcessEnv): ExecFn;
package/dist/exec.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { execFile, spawn } from "node:child_process";
2
2
  import { cmdQuote, inferRemoteShell, parseZellijServerPaths, pickIpcDirs, unixDiscoverRemote, windowsDiscoverRemote, windowsInteractiveRemote, wrapWithTmpEnv, } from "./zellij/ipc.js";
3
3
  export const NOT_FOUND_EXIT = 127;
4
+ function localSpawnResult(err, binPath) {
5
+ const errno = typeof err.code === "string" && err.code ? err.code : "error";
6
+ const missing = errno === "ENOENT" || errno === "ENOTDIR";
7
+ return {
8
+ code: missing ? NOT_FOUND_EXIT : 1,
9
+ stdout: "",
10
+ stderr: `${errno}: ${err.message} (bin=${binPath})`,
11
+ spawn: { origin: "local", errno, bin: binPath },
12
+ };
13
+ }
4
14
  /** POSIX single-quoting, for building a command line the remote shell parses. */
5
15
  export function shellQuote(arg) {
6
16
  if (arg === "")
@@ -35,17 +45,104 @@ export function buildSshRemoteCommand(target, args, tmp, timeoutMs, socketDir) {
35
45
  }
36
46
  return withTmp;
37
47
  }
38
- async function discoverRemoteIpc(runner, target, remaining, signal) {
48
+ /** Positive `auto` IPC hits only; failures/cancels never enter this map. */
49
+ export const DEFAULT_IPC_DISCOVERY_TTL_MS = 15_000;
50
+ export const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
51
+ const ipcDiscoveryCache = new Map();
52
+ let ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
53
+ let ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
54
+ const IPC_USER_ENV = [
55
+ "USER",
56
+ "USERNAME",
57
+ "LOGNAME",
58
+ "HOME",
59
+ "USERPROFILE",
60
+ "USERDOMAIN",
61
+ "SSH_AUTH_SOCK",
62
+ "PATH",
63
+ "Path",
64
+ "XDG_CONFIG_HOME",
65
+ ];
66
+ /** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
67
+ export function ipcDiscoveryCacheKey(target, env = {}) {
68
+ const requested = target.tmp?.trim() ?? "";
69
+ const user = IPC_USER_ENV.map((name) => env[name] ?? "");
70
+ return JSON.stringify([
71
+ target.ssh,
72
+ target.host,
73
+ target.options,
74
+ target.remoteBin,
75
+ target.remoteShell ?? "",
76
+ target.mode ?? "ssh",
77
+ requested.toLowerCase() === "auto" ? "auto" : requested,
78
+ user,
79
+ ]);
80
+ }
81
+ /**
82
+ * Probe list for `ZSWARM_TMP=auto`. Known Windows (cmd / interactive / .exe)
83
+ * skips the Unix `ps` fallback; explicit `sh` skips PowerShell. Unknown
84
+ * `zellij` still tries both — a Windows host with remoteBin=zellij looks Unix.
85
+ */
86
+ export function ipcDiscoveryProbes(target) {
39
87
  const shell = inferRemoteShell({
40
88
  explicit: target.remoteShell,
41
89
  remoteBin: target.remoteBin,
42
90
  mode: target.mode,
43
91
  });
44
- // `auto` has no tmp yet, so a Windows host with remoteBin=zellij still looks
45
- // like Unix. Try both listings; pickIpcDirs ignores lines without --server.
46
- const probes = shell === "cmd"
47
- ? [windowsDiscoverRemote(), unixDiscoverRemote()]
48
- : [unixDiscoverRemote(), windowsDiscoverRemote()];
92
+ const windows = windowsDiscoverRemote();
93
+ const unix = unixDiscoverRemote();
94
+ if (shell === "cmd")
95
+ return [windows];
96
+ if (target.remoteShell === "sh")
97
+ return [unix];
98
+ return [unix, windows];
99
+ }
100
+ export function resetIpcDiscoveryCache() {
101
+ ipcDiscoveryCache.clear();
102
+ ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
103
+ ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
104
+ }
105
+ /** Test helper: shrink the positive-hit TTL without exposing the map. */
106
+ export function setIpcDiscoveryTtlMs(ttlMs) {
107
+ ipcDiscoveryTtlMs = Math.max(0, ttlMs);
108
+ }
109
+ export function peekIpcDiscoveryCache(key) {
110
+ const entry = ipcDiscoveryCache.get(key);
111
+ if (!entry)
112
+ return undefined;
113
+ if (entry.expiresAt <= Date.now()) {
114
+ ipcDiscoveryCache.delete(key);
115
+ return undefined;
116
+ }
117
+ return { tmp: entry.dirs.tmp, socketDir: entry.dirs.socketDir };
118
+ }
119
+ /**
120
+ * Canonical SSH + resolved IPC namespace. Listing caches should hash this
121
+ * (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
122
+ * socket context matches across `createSshExec` instances.
123
+ */
124
+ export function sshIpcContextKey(target, env = {}, ipc) {
125
+ return JSON.stringify([ipcDiscoveryCacheKey(target, env), ipc?.tmp ?? "", ipc?.socketDir ?? ""]);
126
+ }
127
+ /** Test helper: shrink the positive-hit map so eviction is observable. */
128
+ export function setIpcDiscoveryCacheLimit(limit) {
129
+ ipcDiscoveryCacheLimit = Math.max(1, limit);
130
+ }
131
+ export function ipcDiscoveryCacheSize() {
132
+ return ipcDiscoveryCache.size;
133
+ }
134
+ function storeIpcDiscovery(key, dirs) {
135
+ ipcDiscoveryCache.delete(key);
136
+ ipcDiscoveryCache.set(key, {
137
+ dirs: { ...dirs },
138
+ expiresAt: Date.now() + ipcDiscoveryTtlMs,
139
+ });
140
+ while (ipcDiscoveryCache.size > ipcDiscoveryCacheLimit) {
141
+ ipcDiscoveryCache.delete(ipcDiscoveryCache.keys().next().value);
142
+ }
143
+ }
144
+ async function discoverRemoteIpc(runner, target, remaining, signal) {
145
+ const probes = ipcDiscoveryProbes(target);
49
146
  for (const probe of probes) {
50
147
  if (signal?.aborted)
51
148
  return undefined;
@@ -56,6 +153,10 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
56
153
  timeoutMs: left,
57
154
  signal,
58
155
  });
156
+ if (result.code !== 0)
157
+ continue;
158
+ if (remaining() <= 0)
159
+ return undefined;
59
160
  const dirs = pickIpcDirs(parseZellijServerPaths(result.stdout));
60
161
  if (dirs)
61
162
  return dirs;
@@ -66,22 +167,63 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
66
167
  * Run the remote zellij over ssh. The whole invocation is quoted into a single
67
168
  * command string, because the remote login shell re-parses it.
68
169
  */
69
- export function createSshExec(target, env) {
170
+ export function createSshExec(target, env, options = {}) {
70
171
  const runner = createExec(target.ssh, env);
71
- let cached;
72
- let autoAttempted = false;
73
- const requestedTmp = target.tmp?.trim() || null;
172
+ const cacheKey = ipcDiscoveryCacheKey(target, env);
173
+ let autoFailed = false;
174
+ let pinnedIpc;
74
175
  const ipcState = {
75
- requested: requestedTmp,
76
- status: !requestedTmp
77
- ? "none"
78
- : requestedTmp.toLowerCase() === "auto"
79
- ? "none"
80
- : "skipped",
81
- ...(requestedTmp && requestedTmp.toLowerCase() !== "auto"
82
- ? { tmp: requestedTmp, socketDir: "" }
83
- : {}),
176
+ requested: null,
177
+ status: "none",
84
178
  };
179
+ function adopt(dirs) {
180
+ if (options.pinIpc) {
181
+ pinnedIpc ??= { ...dirs };
182
+ dirs = pinnedIpc;
183
+ }
184
+ autoFailed = false;
185
+ ipcState.status = "resolved";
186
+ ipcState.tmp = dirs.tmp;
187
+ ipcState.socketDir = dirs.socketDir;
188
+ return { ...dirs };
189
+ }
190
+ /** Seed from a positive hit; unpinned execs drop resolved dirs after TTL. */
191
+ function refreshIpcView() {
192
+ const requested = target.tmp?.trim() || null;
193
+ ipcState.requested = requested;
194
+ if (!requested) {
195
+ ipcState.status = "none";
196
+ delete ipcState.tmp;
197
+ delete ipcState.socketDir;
198
+ return ipcState;
199
+ }
200
+ if (requested.toLowerCase() !== "auto") {
201
+ ipcState.status = "skipped";
202
+ ipcState.tmp = requested;
203
+ ipcState.socketDir = "";
204
+ return ipcState;
205
+ }
206
+ // A high-level client pins its namespace so pane lookup and the following
207
+ // write cannot switch sockets when another caller refreshes discovery.
208
+ if (pinnedIpc)
209
+ return ipcState;
210
+ // Diagnostics describe this exec's last attempt, not a sibling's later
211
+ // success. A subsequent actual invocation may adopt a positive cache hit.
212
+ if (autoFailed)
213
+ return ipcState;
214
+ const cached = peekIpcDiscoveryCache(cacheKey);
215
+ if (cached) {
216
+ adopt(cached);
217
+ return ipcState;
218
+ }
219
+ if (ipcState.status === "resolved") {
220
+ ipcState.status = "none";
221
+ delete ipcState.tmp;
222
+ delete ipcState.socketDir;
223
+ }
224
+ return ipcState;
225
+ }
226
+ refreshIpcView();
85
227
  async function resolveIpc(remaining, signal) {
86
228
  const requested = target.tmp?.trim();
87
229
  if (!requested) {
@@ -96,40 +238,50 @@ export function createSshExec(target, env) {
96
238
  ipcState.socketDir = "";
97
239
  return { tmp: requested, socketDir: "" };
98
240
  }
99
- if (cached) {
100
- ipcState.status = "resolved";
101
- ipcState.tmp = cached.tmp;
102
- ipcState.socketDir = cached.socketDir;
103
- return cached;
104
- }
241
+ if (pinnedIpc)
242
+ return adopt(pinnedIpc);
243
+ const cached = peekIpcDiscoveryCache(cacheKey);
244
+ if (cached)
245
+ return adopt(cached);
105
246
  // Do not re-probe after a failed/expired/cancelled attempt on this exec.
106
- if (autoAttempted)
247
+ // A later createSshExec with the same key still probes — failures are not
248
+ // stored, and in-flight work is never shared across callers' budgets.
249
+ if (autoFailed)
107
250
  return undefined;
108
251
  if (signal?.aborted) {
252
+ autoFailed = true;
109
253
  ipcState.status = "cancelled";
110
254
  return undefined;
111
255
  }
112
256
  if (remaining() <= 0) {
257
+ autoFailed = true;
113
258
  ipcState.status = "expired";
114
259
  return undefined;
115
260
  }
116
- autoAttempted = true;
117
261
  const dirs = await discoverRemoteIpc(runner, target, remaining, signal);
262
+ // Another probe on this client may have pinned a namespace while we were
263
+ // waiting. Its first positive result wins, even if this probe failed.
264
+ if (pinnedIpc)
265
+ return adopt(pinnedIpc);
118
266
  if (signal?.aborted) {
267
+ autoFailed = true;
119
268
  ipcState.status = "cancelled";
120
269
  return undefined;
121
270
  }
122
271
  if (dirs) {
123
- cached = dirs;
124
- ipcState.status = "resolved";
125
- ipcState.tmp = dirs.tmp;
126
- ipcState.socketDir = dirs.socketDir;
127
- return dirs;
272
+ if (remaining() <= 0) {
273
+ autoFailed = true;
274
+ ipcState.status = "expired";
275
+ return undefined;
276
+ }
277
+ storeIpcDiscovery(cacheKey, dirs);
278
+ return adopt(dirs);
128
279
  }
280
+ autoFailed = true;
129
281
  ipcState.status = remaining() <= 0 ? "expired" : "failed";
130
282
  return undefined;
131
283
  }
132
- const exec = async (args, options) => {
284
+ const exec = (async (args, options) => {
133
285
  const deadline = Date.now() + options.timeoutMs;
134
286
  const remaining = () => Math.max(0, deadline - Date.now());
135
287
  const ipc = await resolveIpc(remaining, options.signal);
@@ -156,8 +308,18 @@ export function createSshExec(target, env) {
156
308
  ...options,
157
309
  timeoutMs: left,
158
310
  });
311
+ });
312
+ Object.defineProperty(exec, "ipcState", {
313
+ enumerable: true,
314
+ configurable: true,
315
+ get() {
316
+ return { ...refreshIpcView() };
317
+ },
318
+ });
319
+ exec.prepareIpc = (timeoutMs, signal) => {
320
+ const deadline = Date.now() + timeoutMs;
321
+ return resolveIpc(() => Math.max(0, deadline - Date.now()), signal);
159
322
  };
160
- exec.ipcState = ipcState;
161
323
  return exec;
162
324
  }
163
325
  /** Read stdout until the answer is in hand, then stop caring about the child. */
@@ -197,12 +359,7 @@ function runUntil(binPath, args, env, options) {
197
359
  }
198
360
  options.signal?.addEventListener("abort", onAbort, { once: true });
199
361
  child.on("error", (err) => {
200
- const missing = err.code === "ENOENT" || err.code === "ENOTDIR";
201
- finish({
202
- code: missing ? NOT_FOUND_EXIT : 1,
203
- stdout: "",
204
- stderr: `${err.code ?? "error"}: ${err.message} (bin=${binPath})`,
205
- });
362
+ finish(localSpawnResult(err, binPath));
206
363
  });
207
364
  child.stdout.on("data", (chunk) => {
208
365
  stdout += chunk.toString();
@@ -231,12 +388,7 @@ function runToExit(binPath, args, env, options) {
231
388
  }, (error, stdout, stderr) => {
232
389
  const failure = error;
233
390
  if (failure && typeof failure.code === "string") {
234
- const missing = failure.code === "ENOENT" || failure.code === "ENOTDIR";
235
- resolve({
236
- code: missing ? NOT_FOUND_EXIT : 1,
237
- stdout: "",
238
- stderr: `${failure.code}: ${failure.message} (bin=${binPath})`,
239
- });
391
+ resolve(localSpawnResult(failure, binPath));
240
392
  return;
241
393
  }
242
394
  if (failure &&
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ZellijError } from "./errors.js";
2
+ export { ListingCache } from "./zellij/cache.js";
2
3
  export { createExec, NOT_FOUND_EXIT, type ExecFn, type ExecOptions, type ExecResult, } from "./exec.js";
3
4
  export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, type GitClient, type GitClientOptions, type Worktree, } from "./git.js";
4
5
  export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
@@ -14,13 +15,17 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, type Policy,
14
15
  export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, type IpcDiscoveryState, type SshExecFn, type SshTarget, } from "./exec.js";
15
16
  export { resolveSshTarget } from "./zellij/binary.js";
16
17
  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, isLoopbackHost, parseListenAddress, redactServeSecret, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
18
+ export { callServe, DEFAULT_SERVE_LISTEN, isLoopbackHost, parseListenAddress, probeServe, redactServeSecret, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_CAPABILITY_HELLO, SERVE_CONNECT_TIMEOUT_MS, SERVE_CONTROL_FIELD, SERVE_HELLO_CONTROL, SERVE_HELLO_TIMEOUT_MS, SERVE_CORE_VERSION, SERVE_LAUNCH_ID_ENV, SERVE_MAX_HELLO_BYTES, SERVE_MAX_REPLY_BYTES, SERVE_MAX_REQUEST_BYTES, SERVE_CAPTURE_BUDGET_BYTES, SERVE_PROTOCOL, SERVE_REMEDY, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, serveMaxReplyBytes, startServe, type CallServeOptions, type ProbeServeOptions, type ServeCapability, type ServeDelivery, type ServeErrorDetails, type ServeHelloData, type ServePhase, type StartServeOptions, } from "./ops/serve.js";
19
+ export { authorizeServeListen, canonicalizeListenIp, expandIPv6, sameCanonicalIp, SERVE_BIND_REMEDY, SERVE_BIND_STATUS_MAX_BYTES, SERVE_BIND_VERIFY_TIMEOUT_MS, SERVE_TAILSCALE_BIN_ENV, type ServeBindAuthorization, type ServeBindDeps, } from "./ops/serve-bind.js";
20
+ export { buildServeTaskScript, DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, looksLikeCliEntrypoint, looksLikeMcpEntrypoint, resolveServeCliLaunch, sameWindowsAccount, sameWindowsSid, SERVE_NOT_READY_CODE, SERVE_TASK_OWNED_CODE, serveCommandFingerprint, serveTaskChildEnv, serveTaskLaunchEnv, uninstallServeLogon, type ServeCliLaunch, type ServeInstallInput, type ServeInstallSuccess, type ServeTaskAction, } from "./ops/serve-install.js";
21
+ export { allocateLoopbackPort, assertSafeSshTunnelOpts, buildSshTunnelArgv, createServeTunnelManager, DEFAULT_SSH_PORT, DEFAULT_SSH_SERVE_PORT, describeServeTarget, formatSshServeTarget, forwardServe, isSshServeTarget, parseSshServeTarget, redactSshUserinfo, serveTunnelCacheKey, SSH_SERVE_REMOTE_HOST, SSH_TUNNEL_KEEPALIVE_COUNT, SSH_TUNNEL_KEEPALIVE_INTERVAL_S, stripControllerRouting, type AcquireServeTunnelOptions, type ParsedSshServeTarget, type ServeTunnelHandle, type ServeTunnelManager, type ServeTunnelManagerOptions, } from "./ops/serve-tunnel.js";
18
22
  export { parseTabList, resolveTab, type ZellijTab } from "./zellij/tabs.js";
19
23
  export { selectTargets } from "./ops/broadcast.js";
20
24
  export { diffScreens, cursorKey } from "./ops/tail.js";
21
25
  export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, type PeerState, } from "./ops/status.js";
22
26
  export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
23
27
  export type { RoutingContext } from "./ops/routing.js";
24
- export type { DispatchDeps, OpsResult } from "./ops/types.js";
28
+ export type { DispatchDeps, OpsResult, ServeInstallDeps } from "./ops/types.js";
29
+ export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, type DoctorCheck, type DoctorCheckScope, type DoctorCheckState, type DoctorReport, type DoctorRoute, type HostInspectInput, } from "./ops/doctor.js";
25
30
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
26
31
  export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, type OpName, type ParamSpec, type ParamType, } from "./schema.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ZellijError } from "./errors.js";
2
+ export { ListingCache } from "./zellij/cache.js";
2
3
  export { createExec, NOT_FOUND_EXIT, } from "./exec.js";
3
4
  export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeList, worktreeDirName, } from "./git.js";
4
5
  export { expandHomePath, looksLikeZswarmBinary, isZellijVersionOutput, resolveZellijBinary, resetZellijIdentityCache, sanitizeZellijEnv, validateSshDestination, validateSshMode, } from "./zellij/binary.js";
@@ -14,11 +15,15 @@ export { assertOpAllowed, assertPaneAllowed, isWriteOp, loadPolicy, } from "./po
14
15
  export { buildSshRemoteCommand, createSshExec, quoteRemoteArg, shellQuote, } from "./exec.js";
15
16
  export { resolveSshTarget } from "./zellij/binary.js";
16
17
  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, isLoopbackHost, parseListenAddress, redactServeSecret, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.js";
18
+ export { callServe, DEFAULT_SERVE_LISTEN, isLoopbackHost, parseListenAddress, probeServe, redactServeSecret, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_CAPABILITY_HELLO, SERVE_CONNECT_TIMEOUT_MS, SERVE_CONTROL_FIELD, SERVE_HELLO_CONTROL, SERVE_HELLO_TIMEOUT_MS, SERVE_CORE_VERSION, SERVE_LAUNCH_ID_ENV, SERVE_MAX_HELLO_BYTES, SERVE_MAX_REPLY_BYTES, SERVE_MAX_REQUEST_BYTES, SERVE_CAPTURE_BUDGET_BYTES, SERVE_PROTOCOL, SERVE_REMEDY, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, serveMaxReplyBytes, startServe, } from "./ops/serve.js";
19
+ export { authorizeServeListen, canonicalizeListenIp, expandIPv6, sameCanonicalIp, SERVE_BIND_REMEDY, SERVE_BIND_STATUS_MAX_BYTES, SERVE_BIND_VERIFY_TIMEOUT_MS, SERVE_TAILSCALE_BIN_ENV, } from "./ops/serve-bind.js";
20
+ export { buildServeTaskScript, DEFAULT_SERVE_INSTALL_TIMEOUT_MS, installServeLogon, looksLikeCliEntrypoint, looksLikeMcpEntrypoint, resolveServeCliLaunch, sameWindowsAccount, sameWindowsSid, SERVE_NOT_READY_CODE, SERVE_TASK_OWNED_CODE, serveCommandFingerprint, serveTaskChildEnv, serveTaskLaunchEnv, uninstallServeLogon, } from "./ops/serve-install.js";
21
+ export { allocateLoopbackPort, assertSafeSshTunnelOpts, buildSshTunnelArgv, createServeTunnelManager, DEFAULT_SSH_PORT, DEFAULT_SSH_SERVE_PORT, describeServeTarget, formatSshServeTarget, forwardServe, isSshServeTarget, parseSshServeTarget, redactSshUserinfo, serveTunnelCacheKey, SSH_SERVE_REMOTE_HOST, SSH_TUNNEL_KEEPALIVE_COUNT, SSH_TUNNEL_KEEPALIVE_INTERVAL_S, stripControllerRouting, } from "./ops/serve-tunnel.js";
18
22
  export { parseTabList, resolveTab } from "./zellij/tabs.js";
19
23
  export { selectTargets } from "./ops/broadcast.js";
20
24
  export { diffScreens, cursorKey } from "./ops/tail.js";
21
25
  export { classify, lastLine, mapPool, peerStatus, DEFAULT_STATUS_TIMEOUT_MS, STATUS_DUMP_CONCURRENCY, } from "./ops/status.js";
22
26
  export { normalizeScreen, truncateDumpText, DEFAULT_DUMP_MAX_CHARS, DEFAULT_WAIT_MAX_CHARS, } from "./ops/util.js";
27
+ export { DEFAULT_DOCTOR_TIMEOUT_MS, DOCTOR_FAILED_CODE, DOCTOR_SCOPE_FIELD, DOCTOR_SCOPE_HOST, DOCTOR_TAILSCALE_MAX_MS, HOST_REPORT_INCOMPLETE_CODE, HOST_REPORT_INVALID_CODE, doctorOp, inspectDoctorHost, coverHostReport, hostDoctorRequest, isRequiredFailure, mergeHostReply, } from "./ops/doctor.js";
23
28
  export { normalizeKey, normalizeKeys, tokenizeCommand } from "./keys.js";
24
29
  export { cliUsage, mcpInputSchema, parseCliArgv, MCP_TOOL_DESCRIPTION, OP_NAMES, PARAMS, TARGET_OPS, } from "./schema.js";
@@ -70,7 +70,7 @@ export async function broadcast(client, state, args, clock, policy, env = proces
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
- const panes = await client.listPanes(session);
73
+ const panes = await client.listPanes(session, undefined, { fresh: true });
74
74
  const labeled = withSenderLabel(args, {
75
75
  env,
76
76
  selfTitle: selfPaneTitle(client, panes),
package/dist/ops/bus.js CHANGED
@@ -19,15 +19,18 @@ const COLD_RETRY_MS = 200;
19
19
  * copies.
20
20
  */
21
21
  const INSTALL_IN_FLIGHT_MS = 10_000;
22
- /** Set once a pipe has failed, so one dead plugin costs one timeout, not many. */
23
- let processDisabled = null;
22
+ /** Back off failed pipes briefly, isolated to their transport/session/plugin. */
23
+ const busFailures = new Map();
24
24
  /** The key that actually answered, per session, for the life of this process. */
25
25
  const answeredWith = new Map();
26
26
  /** Tests share a module instance; this drops what a previous case cached. */
27
27
  export function resetBusCache() {
28
- processDisabled = null;
28
+ busFailures.clear();
29
29
  answeredWith.clear();
30
30
  }
31
+ function busScope(client, session, plan) {
32
+ return JSON.stringify([client.contextKey(), session, plan.url, plan.configKey]);
33
+ }
31
34
  function affirmative(raw) {
32
35
  const v = (raw ?? "").trim().toLowerCase();
33
36
  return v === "1" || v === "true" || v === "yes" || v === "on";
@@ -62,8 +65,12 @@ export function planBus(client, state, env = process.env, session = "") {
62
65
  reason: "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN",
63
66
  };
64
67
  }
65
- if (processDisabled)
66
- return { ...base, reason: processDisabled };
68
+ const scope = busScope(client, session, base);
69
+ const failed = busFailures.get(scope);
70
+ if (failed && Date.now() < failed.until)
71
+ return { ...base, reason: failed.reason };
72
+ if (failed)
73
+ busFailures.delete(scope);
67
74
  if (affirmative(env.ZSWARM_BUS)) {
68
75
  return { ...base, enabled: true, reason: "ZSWARM_BUS=1" };
69
76
  }
@@ -89,7 +96,7 @@ async function nudgeManifest(client, session, env, remaining) {
89
96
  const listBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
90
97
  if (listBudget <= 0)
91
98
  return;
92
- const panes = await client.listPanes(session, listBudget);
99
+ const panes = await client.listPanes(session, listBudget, { fresh: true });
93
100
  const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
94
101
  if (!pane)
95
102
  return;
@@ -127,7 +134,8 @@ export async function busSnapshot(client, state, session, clock, env = process.e
127
134
  const url = plan.url;
128
135
  if (!plan.enabled || !url || !pluginPath)
129
136
  return null;
130
- const configKey = answeredWith.get(session) ?? plan.configKey;
137
+ const scope = busScope(client, session, plan);
138
+ const configKey = answeredWith.get(scope) ?? plan.configKey;
131
139
  const ask = async () => {
132
140
  const budget = Math.min(DEFAULT_BUS_TIMEOUT_MS, remaining());
133
141
  if (budget <= 0)
@@ -155,10 +163,18 @@ export async function busSnapshot(client, state, session, clock, env = process.e
155
163
  if (remaining() <= 0 && !snapshot?.ready)
156
164
  return null;
157
165
  if (snapshot) {
158
- answeredWith.set(session, configKey);
166
+ answeredWith.set(scope, configKey);
167
+ if (answeredWith.size > 256)
168
+ answeredWith.delete(answeredWith.keys().next().value);
169
+ if (snapshot.ready)
170
+ client.observeManifest(session, JSON.stringify([
171
+ scope, snapshot.paneUpdates, snapshot.tabUpdates, snapshot.panes, snapshot.tabs, snapshot.tabIds,
172
+ ]));
159
173
  return { snapshot, configKey };
160
174
  }
161
- processDisabled = "plugin did not answer; using zellij polling for this run";
175
+ busFailures.set(scope, { reason: "plugin did not answer; using zellij polling for 5s", until: Date.now() + 5000 });
176
+ if (busFailures.size > 256)
177
+ busFailures.delete(busFailures.keys().next().value);
162
178
  return null;
163
179
  }
164
180
  /**
@@ -178,7 +194,7 @@ export async function busScreens(client, state, session, paneIds, clock, env = p
178
194
  const plan = planBus(client, state, env, session);
179
195
  if (!plan.enabled || !plan.url)
180
196
  return null;
181
- const configKey = answeredWith.get(session) ?? plan.configKey;
197
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
182
198
  const reply = await client.scrollbackPlugin({
183
199
  session,
184
200
  url: plan.url,
@@ -209,7 +225,7 @@ export async function busWait(client, state, session, request, env = process.env
209
225
  const plan = planBus(client, state, env, session);
210
226
  if (!plan.enabled || !plan.url)
211
227
  return null;
212
- const configKey = answeredWith.get(session) ?? plan.configKey;
228
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
213
229
  const reply = await client.waitPlugin({
214
230
  session,
215
231
  url: plan.url,
@@ -232,7 +248,7 @@ export async function busChanged(client, state, session, paneIds, env = process.
232
248
  const plan = planBus(client, state, env, session);
233
249
  if (!plan.enabled || !plan.url)
234
250
  return null;
235
- const configKey = answeredWith.get(session) ?? plan.configKey;
251
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
236
252
  const reply = await client.changedPlugin({
237
253
  session,
238
254
  url: plan.url,
@@ -243,7 +259,8 @@ export async function busChanged(client, state, session, paneIds, env = process.
243
259
  const parsed = parseChangedReply(reply.stdout);
244
260
  if (!parsed || !parsed.ready)
245
261
  return null;
246
- return parsed.panes.length === paneIds.length ? parsed : null;
262
+ const received = new Set([...parsed.panes.map((pane) => pane.id), ...parsed.missing]);
263
+ return paneIds.every((id) => received.has(id)) ? parsed : null;
247
264
  }
248
265
  function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
249
266
  return {
@@ -261,7 +278,7 @@ function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
261
278
  };
262
279
  }
263
280
  async function listBusPluginPanes(client, session, pluginPath) {
264
- const panes = await client.listPanes(session);
281
+ const panes = await client.listPanes(session, undefined, { fresh: true });
265
282
  return panes.filter((pane) => isBusPluginPane(pane, pluginPath));
266
283
  }
267
284
  /** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
@@ -279,8 +296,15 @@ export async function closeBusPluginPanes(client, session, pluginPath) {
279
296
  }
280
297
  return closed;
281
298
  }
299
+ /** Effective SSH transport — not controller OS, not leftover ZSWARM_SSH in env. */
300
+ function isDirectSshClient(client) {
301
+ return client.remote || client.transport.kind === "ssh";
302
+ }
282
303
  /** Report on the bus, install it, or forget it. */
283
304
  export async function busOp(client, state, args, clock, env = process.env, policy) {
305
+ if ((isTrue(args.install) || isTrue(args.clear)) && isDirectSshClient(client)) {
306
+ throw new ZellijError("bus_remote_unsupported", "run on Zellij host or use --serve through a tunnel; direct SSH cannot install/clear the bus");
307
+ }
284
308
  const sessionArg = typeof args.session === "string" ? args.session : undefined;
285
309
  if (isTrue(args.clear)) {
286
310
  let session = null;