@zswarm/core 0.1.6 → 0.1.7

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
@@ -1,4 +1,4 @@
1
- import { type RemoteShell } from "./zellij/ipc.js";
1
+ import { type IpcDirs, type RemoteShell } from "./zellij/ipc.js";
2
2
  export type ExecResult = {
3
3
  code: number;
4
4
  stdout: string;
@@ -49,10 +49,44 @@ export type IpcDiscoveryState = {
49
49
  };
50
50
  export type SshExecFn = ExecFn & {
51
51
  ipcState: IpcDiscoveryState;
52
+ /**
53
+ * Resolve `ZSWARM_TMP=auto` once under this caller's budget before parallel
54
+ * identity/capability probes. Independent of other callers' in-flight work.
55
+ */
56
+ prepareIpc: (timeoutMs: number, signal?: AbortSignal) => Promise<IpcDirs | undefined>;
52
57
  };
58
+ /** Positive `auto` IPC hits only; failures/cancels never enter this map. */
59
+ export declare const DEFAULT_IPC_DISCOVERY_TTL_MS = 15000;
60
+ export declare const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
61
+ /** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
62
+ export declare function ipcDiscoveryCacheKey(target: SshTarget, env?: NodeJS.ProcessEnv): string;
63
+ /**
64
+ * Probe list for `ZSWARM_TMP=auto`. Known Windows (cmd / interactive / .exe)
65
+ * skips the Unix `ps` fallback; explicit `sh` skips PowerShell. Unknown
66
+ * `zellij` still tries both — a Windows host with remoteBin=zellij looks Unix.
67
+ */
68
+ export declare function ipcDiscoveryProbes(target: SshTarget): string[];
69
+ export declare function resetIpcDiscoveryCache(): void;
70
+ /** Test helper: shrink the positive-hit TTL without exposing the map. */
71
+ export declare function setIpcDiscoveryTtlMs(ttlMs: number): void;
72
+ export declare function peekIpcDiscoveryCache(key: string): IpcDirs | undefined;
73
+ /**
74
+ * Canonical SSH + resolved IPC namespace. Listing caches should hash this
75
+ * (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
76
+ * socket context matches across `createSshExec` instances.
77
+ */
78
+ export declare function sshIpcContextKey(target: SshTarget, env?: NodeJS.ProcessEnv, ipc?: {
79
+ tmp?: string;
80
+ socketDir?: string;
81
+ } | null): string;
82
+ /** Test helper: shrink the positive-hit map so eviction is observable. */
83
+ export declare function setIpcDiscoveryCacheLimit(limit: number): void;
84
+ export declare function ipcDiscoveryCacheSize(): number;
53
85
  /**
54
86
  * Run the remote zellij over ssh. The whole invocation is quoted into a single
55
87
  * command string, because the remote login shell re-parses it.
56
88
  */
57
- export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv): SshExecFn;
89
+ export declare function createSshExec(target: SshTarget, env: NodeJS.ProcessEnv, options?: {
90
+ pinIpc?: boolean;
91
+ }): SshExecFn;
58
92
  export declare function createExec(binPath: string, env: NodeJS.ProcessEnv): ExecFn;
package/dist/exec.js CHANGED
@@ -35,17 +35,104 @@ export function buildSshRemoteCommand(target, args, tmp, timeoutMs, socketDir) {
35
35
  }
36
36
  return withTmp;
37
37
  }
38
- async function discoverRemoteIpc(runner, target, remaining, signal) {
38
+ /** Positive `auto` IPC hits only; failures/cancels never enter this map. */
39
+ export const DEFAULT_IPC_DISCOVERY_TTL_MS = 15_000;
40
+ export const DEFAULT_IPC_DISCOVERY_CACHE_LIMIT = 512;
41
+ const ipcDiscoveryCache = new Map();
42
+ let ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
43
+ let ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
44
+ const IPC_USER_ENV = [
45
+ "USER",
46
+ "USERNAME",
47
+ "LOGNAME",
48
+ "HOME",
49
+ "USERPROFILE",
50
+ "USERDOMAIN",
51
+ "SSH_AUTH_SOCK",
52
+ "PATH",
53
+ "Path",
54
+ "XDG_CONFIG_HOME",
55
+ ];
56
+ /** Fully scoped key so hosts / SSH bins / remote shells / users never collide. */
57
+ export function ipcDiscoveryCacheKey(target, env = {}) {
58
+ const requested = target.tmp?.trim() ?? "";
59
+ const user = IPC_USER_ENV.map((name) => env[name] ?? "");
60
+ return JSON.stringify([
61
+ target.ssh,
62
+ target.host,
63
+ target.options,
64
+ target.remoteBin,
65
+ target.remoteShell ?? "",
66
+ target.mode ?? "ssh",
67
+ requested.toLowerCase() === "auto" ? "auto" : requested,
68
+ user,
69
+ ]);
70
+ }
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 function ipcDiscoveryProbes(target) {
39
77
  const shell = inferRemoteShell({
40
78
  explicit: target.remoteShell,
41
79
  remoteBin: target.remoteBin,
42
80
  mode: target.mode,
43
81
  });
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()];
82
+ const windows = windowsDiscoverRemote();
83
+ const unix = unixDiscoverRemote();
84
+ if (shell === "cmd")
85
+ return [windows];
86
+ if (target.remoteShell === "sh")
87
+ return [unix];
88
+ return [unix, windows];
89
+ }
90
+ export function resetIpcDiscoveryCache() {
91
+ ipcDiscoveryCache.clear();
92
+ ipcDiscoveryTtlMs = DEFAULT_IPC_DISCOVERY_TTL_MS;
93
+ ipcDiscoveryCacheLimit = DEFAULT_IPC_DISCOVERY_CACHE_LIMIT;
94
+ }
95
+ /** Test helper: shrink the positive-hit TTL without exposing the map. */
96
+ export function setIpcDiscoveryTtlMs(ttlMs) {
97
+ ipcDiscoveryTtlMs = Math.max(0, ttlMs);
98
+ }
99
+ export function peekIpcDiscoveryCache(key) {
100
+ const entry = ipcDiscoveryCache.get(key);
101
+ if (!entry)
102
+ return undefined;
103
+ if (entry.expiresAt <= Date.now()) {
104
+ ipcDiscoveryCache.delete(key);
105
+ return undefined;
106
+ }
107
+ return { tmp: entry.dirs.tmp, socketDir: entry.dirs.socketDir };
108
+ }
109
+ /**
110
+ * Canonical SSH + resolved IPC namespace. Listing caches should hash this
111
+ * (or `ipcState.tmp` / `socketDir` after a refreshed read) so a warmed
112
+ * socket context matches across `createSshExec` instances.
113
+ */
114
+ export function sshIpcContextKey(target, env = {}, ipc) {
115
+ return JSON.stringify([ipcDiscoveryCacheKey(target, env), ipc?.tmp ?? "", ipc?.socketDir ?? ""]);
116
+ }
117
+ /** Test helper: shrink the positive-hit map so eviction is observable. */
118
+ export function setIpcDiscoveryCacheLimit(limit) {
119
+ ipcDiscoveryCacheLimit = Math.max(1, limit);
120
+ }
121
+ export function ipcDiscoveryCacheSize() {
122
+ return ipcDiscoveryCache.size;
123
+ }
124
+ function storeIpcDiscovery(key, dirs) {
125
+ ipcDiscoveryCache.delete(key);
126
+ ipcDiscoveryCache.set(key, {
127
+ dirs: { ...dirs },
128
+ expiresAt: Date.now() + ipcDiscoveryTtlMs,
129
+ });
130
+ while (ipcDiscoveryCache.size > ipcDiscoveryCacheLimit) {
131
+ ipcDiscoveryCache.delete(ipcDiscoveryCache.keys().next().value);
132
+ }
133
+ }
134
+ async function discoverRemoteIpc(runner, target, remaining, signal) {
135
+ const probes = ipcDiscoveryProbes(target);
49
136
  for (const probe of probes) {
50
137
  if (signal?.aborted)
51
138
  return undefined;
@@ -56,6 +143,10 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
56
143
  timeoutMs: left,
57
144
  signal,
58
145
  });
146
+ if (result.code !== 0)
147
+ continue;
148
+ if (remaining() <= 0)
149
+ return undefined;
59
150
  const dirs = pickIpcDirs(parseZellijServerPaths(result.stdout));
60
151
  if (dirs)
61
152
  return dirs;
@@ -66,22 +157,63 @@ async function discoverRemoteIpc(runner, target, remaining, signal) {
66
157
  * Run the remote zellij over ssh. The whole invocation is quoted into a single
67
158
  * command string, because the remote login shell re-parses it.
68
159
  */
69
- export function createSshExec(target, env) {
160
+ export function createSshExec(target, env, options = {}) {
70
161
  const runner = createExec(target.ssh, env);
71
- let cached;
72
- let autoAttempted = false;
73
- const requestedTmp = target.tmp?.trim() || null;
162
+ const cacheKey = ipcDiscoveryCacheKey(target, env);
163
+ let autoFailed = false;
164
+ let pinnedIpc;
74
165
  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
- : {}),
166
+ requested: null,
167
+ status: "none",
84
168
  };
169
+ function adopt(dirs) {
170
+ if (options.pinIpc) {
171
+ pinnedIpc ??= { ...dirs };
172
+ dirs = pinnedIpc;
173
+ }
174
+ autoFailed = false;
175
+ ipcState.status = "resolved";
176
+ ipcState.tmp = dirs.tmp;
177
+ ipcState.socketDir = dirs.socketDir;
178
+ return { ...dirs };
179
+ }
180
+ /** Seed from a positive hit; unpinned execs drop resolved dirs after TTL. */
181
+ function refreshIpcView() {
182
+ const requested = target.tmp?.trim() || null;
183
+ ipcState.requested = requested;
184
+ if (!requested) {
185
+ ipcState.status = "none";
186
+ delete ipcState.tmp;
187
+ delete ipcState.socketDir;
188
+ return ipcState;
189
+ }
190
+ if (requested.toLowerCase() !== "auto") {
191
+ ipcState.status = "skipped";
192
+ ipcState.tmp = requested;
193
+ ipcState.socketDir = "";
194
+ return ipcState;
195
+ }
196
+ // A high-level client pins its namespace so pane lookup and the following
197
+ // write cannot switch sockets when another caller refreshes discovery.
198
+ if (pinnedIpc)
199
+ return ipcState;
200
+ // Diagnostics describe this exec's last attempt, not a sibling's later
201
+ // success. A subsequent actual invocation may adopt a positive cache hit.
202
+ if (autoFailed)
203
+ return ipcState;
204
+ const cached = peekIpcDiscoveryCache(cacheKey);
205
+ if (cached) {
206
+ adopt(cached);
207
+ return ipcState;
208
+ }
209
+ if (ipcState.status === "resolved") {
210
+ ipcState.status = "none";
211
+ delete ipcState.tmp;
212
+ delete ipcState.socketDir;
213
+ }
214
+ return ipcState;
215
+ }
216
+ refreshIpcView();
85
217
  async function resolveIpc(remaining, signal) {
86
218
  const requested = target.tmp?.trim();
87
219
  if (!requested) {
@@ -96,40 +228,50 @@ export function createSshExec(target, env) {
96
228
  ipcState.socketDir = "";
97
229
  return { tmp: requested, socketDir: "" };
98
230
  }
99
- if (cached) {
100
- ipcState.status = "resolved";
101
- ipcState.tmp = cached.tmp;
102
- ipcState.socketDir = cached.socketDir;
103
- return cached;
104
- }
231
+ if (pinnedIpc)
232
+ return adopt(pinnedIpc);
233
+ const cached = peekIpcDiscoveryCache(cacheKey);
234
+ if (cached)
235
+ return adopt(cached);
105
236
  // Do not re-probe after a failed/expired/cancelled attempt on this exec.
106
- if (autoAttempted)
237
+ // A later createSshExec with the same key still probes — failures are not
238
+ // stored, and in-flight work is never shared across callers' budgets.
239
+ if (autoFailed)
107
240
  return undefined;
108
241
  if (signal?.aborted) {
242
+ autoFailed = true;
109
243
  ipcState.status = "cancelled";
110
244
  return undefined;
111
245
  }
112
246
  if (remaining() <= 0) {
247
+ autoFailed = true;
113
248
  ipcState.status = "expired";
114
249
  return undefined;
115
250
  }
116
- autoAttempted = true;
117
251
  const dirs = await discoverRemoteIpc(runner, target, remaining, signal);
252
+ // Another probe on this client may have pinned a namespace while we were
253
+ // waiting. Its first positive result wins, even if this probe failed.
254
+ if (pinnedIpc)
255
+ return adopt(pinnedIpc);
118
256
  if (signal?.aborted) {
257
+ autoFailed = true;
119
258
  ipcState.status = "cancelled";
120
259
  return undefined;
121
260
  }
122
261
  if (dirs) {
123
- cached = dirs;
124
- ipcState.status = "resolved";
125
- ipcState.tmp = dirs.tmp;
126
- ipcState.socketDir = dirs.socketDir;
127
- return dirs;
262
+ if (remaining() <= 0) {
263
+ autoFailed = true;
264
+ ipcState.status = "expired";
265
+ return undefined;
266
+ }
267
+ storeIpcDiscovery(cacheKey, dirs);
268
+ return adopt(dirs);
128
269
  }
270
+ autoFailed = true;
129
271
  ipcState.status = remaining() <= 0 ? "expired" : "failed";
130
272
  return undefined;
131
273
  }
132
- const exec = async (args, options) => {
274
+ const exec = (async (args, options) => {
133
275
  const deadline = Date.now() + options.timeoutMs;
134
276
  const remaining = () => Math.max(0, deadline - Date.now());
135
277
  const ipc = await resolveIpc(remaining, options.signal);
@@ -156,8 +298,18 @@ export function createSshExec(target, env) {
156
298
  ...options,
157
299
  timeoutMs: left,
158
300
  });
301
+ });
302
+ Object.defineProperty(exec, "ipcState", {
303
+ enumerable: true,
304
+ configurable: true,
305
+ get() {
306
+ return { ...refreshIpcView() };
307
+ },
308
+ });
309
+ exec.prepareIpc = (timeoutMs, signal) => {
310
+ const deadline = Date.now() + timeoutMs;
311
+ return resolveIpc(() => Math.max(0, deadline - Date.now()), signal);
159
312
  };
160
- exec.ipcState = ipcState;
161
313
  return exec;
162
314
  }
163
315
  /** Read stdout until the answer is in hand, then stop caring about the child. */
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";
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";
@@ -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. */
@@ -6,7 +6,7 @@ import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
6
6
  import { createStateStore } from "../state.js";
7
7
  import { busToPanes } from "../zellij/bus.js";
8
8
  import { broadcast } from "./broadcast.js";
9
- import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
9
+ import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
10
10
  import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
11
11
  import { peerCheckpoint, peerDiff } from "./review.js";
12
12
  import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
@@ -31,12 +31,18 @@ import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, }
31
31
  export function resolveInvocationEnv(args, base) {
32
32
  const local = isTrue(args.local);
33
33
  const sshDest = typeof args.ssh === "string" && args.ssh.trim() ? args.ssh.trim() : null;
34
- if (local && sshDest) {
35
- throw new ZellijError("usage", "pass --local or --ssh, not both");
34
+ const serveAddress = optionalString(args.serveAddress);
35
+ if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
36
+ throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
36
37
  }
37
- if (!local && !sshDest)
38
+ if (!local && !sshDest && !serveAddress)
38
39
  return base;
39
40
  const env = { ...base };
41
+ if (serveAddress) {
42
+ env.ZSWARM_SERVE = serveAddress;
43
+ delete env.ZSWARM_SSH;
44
+ return env;
45
+ }
40
46
  if (local) {
41
47
  delete env.ZSWARM_SSH;
42
48
  delete env.ZSWARM_SSH_OPTS;
@@ -107,9 +113,10 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
107
113
  }
108
114
  const until = Math.min(budget.deadline, clock.now() + observeMs);
109
115
  let pane;
116
+ let fresh = isTrue(args.fresh) || Boolean(op);
110
117
  for (;;) {
111
118
  const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
112
- panes ??= await client.listPanes(session, left);
119
+ panes ??= await client.listPanes(session, left, { fresh });
113
120
  throwIfAborted(signal);
114
121
  try {
115
122
  pane = client.resolvePane(panes, to);
@@ -122,6 +129,7 @@ async function resolveTarget(client, args, state, clock, env, policy, op, signal
122
129
  if (clock.now() >= until)
123
130
  throw err;
124
131
  panes = null;
132
+ fresh = true;
125
133
  }
126
134
  }
127
135
  if (policy && op)
@@ -181,9 +189,11 @@ async function dispatchOperation(args, injected, deps, context, env) {
181
189
  // An injected client is a unit-test (or in-process) Zellij; do not skip it
182
190
  // just because the host env has ZSWARM_SERVE set.
183
191
  if (!injected && env.ZSWARM_SERVE?.trim()) {
184
- return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
192
+ const request = { ...attachKnownSender(args, env) };
193
+ delete request.serveAddress; // Routing is consumed here, never forwarded back into a tunnel.
194
+ return await callServe(env.ZSWARM_SERVE.trim(), request, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
185
195
  }
186
- const baseClient = injected ?? createZellijClient({ env, signal });
196
+ const baseClient = injected ?? createZellijClient({ env, signal, cache: isTrue(args.fresh) ? false : undefined });
187
197
  const client = {
188
198
  ...baseClient,
189
199
  get transport() { return baseClient.transport; },
@@ -224,9 +234,10 @@ async function dispatchOperation(args, injected, deps, context, env) {
224
234
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
225
235
  // The plugin's manifest carries no cwd or floating flag, so anything
226
236
  // verbose has to go the polling route.
227
- const bus = verbose
237
+ const observedBus = verbose
228
238
  ? null
229
239
  : await busSnapshot(client, state(), session, clock, env);
240
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
230
241
  const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
231
242
  const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
232
243
  .filter((p) => !p.isPlugin)
@@ -391,24 +402,39 @@ async function dispatchOperation(args, injected, deps, context, env) {
391
402
  throwIfAborted(signal);
392
403
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
393
404
  throwIfAborted(signal);
394
- // Verbose reports cwd and command, which the plugin manifest lacks.
395
- const bus = verbose || remaining() <= 0
396
- ? null
397
- : await busSnapshot(client, state(), session, clock, env, "status", {
398
- deadlineAt,
399
- signal,
400
- });
405
+ // Once the session is known, verbose metadata and the bus manifest
406
+ // are independent reads. Both consume this operation's remaining time.
407
+ const readBus = () => remaining() <= 0 ? Promise.resolve(null)
408
+ : busSnapshot(client, state(), session, clock, env, "status", { deadlineAt, signal });
409
+ let observedBus;
410
+ let detailedPanes;
411
+ if (verbose) {
412
+ const reads = await Promise.allSettled([
413
+ readBus(), client.listPanes(session, setupBudget(), { fresh: true }),
414
+ ]);
415
+ throwIfAborted(signal);
416
+ if (reads[1].status === "rejected")
417
+ throw reads[1].reason;
418
+ if (reads[0].status === "rejected")
419
+ throw reads[0].reason;
420
+ observedBus = reads[0].value;
421
+ detailedPanes = reads[1].value;
422
+ }
423
+ else {
424
+ observedBus = await readBus();
425
+ }
426
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
401
427
  throwIfAborted(signal);
402
428
  // Always hand peerStatus the resolved session (and panes) so it does
403
429
  // not spend the overall budget resolving again.
404
- const panes = bus
430
+ const panes = detailedPanes ?? (bus
405
431
  ? busToPanes(bus.snapshot)
406
- : await client.listPanes(session, setupBudget());
432
+ : await client.listPanes(session, setupBudget()));
407
433
  throwIfAborted(signal);
408
434
  let supplied = {
409
435
  session,
410
436
  panes,
411
- source: bus ? "plugin" : "zellij",
437
+ source: bus && !verbose ? "plugin" : "zellij",
412
438
  };
413
439
  if (bus) {
414
440
  supplied = {
@@ -433,13 +459,24 @@ async function dispatchOperation(args, injected, deps, context, env) {
433
459
  catch {
434
460
  // Command-shaped `to` needs the polled list; re-fetch without bus.
435
461
  const polled = await client.listPanes(session, setupBudget());
436
- supplied = { session, panes: polled, source: "zellij" };
462
+ supplied = { ...supplied, session, panes: polled, source: "zellij" };
437
463
  }
438
464
  }
439
- return await peerStatus(client, args, clock, supplied, {
465
+ const result = await peerStatus(client, args, clock, supplied, {
440
466
  deadlineAt,
441
467
  signal,
442
468
  });
469
+ if (result.ok && result.data && typeof result.data === "object") {
470
+ const data = result.data;
471
+ data.polling = {
472
+ busAvailable: Boolean(bus),
473
+ ...(bus ? {} : { reason: planBus(client, state(), env, session).reason }),
474
+ ...(client.remote ? {
475
+ recommendation: "For frequent status calls, run zswarm serve beside Zellij and use --serve 127.0.0.1:9419 through an SSH tunnel; direct SSH cannot use the local event bus.",
476
+ } : {}),
477
+ };
478
+ }
479
+ return result;
443
480
  }
444
481
  case "bus":
445
482
  return await busOp(client, state(), args, clock, env, policy);