@zswarm/core 0.1.4 → 0.1.5

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
@@ -16,6 +16,8 @@ export type ExecOptions = {
16
16
  * timeout.
17
17
  */
18
18
  until?: (stdout: string) => boolean;
19
+ /** Kill the child when aborted (MCP cancellation). */
20
+ signal?: AbortSignal;
19
21
  };
20
22
  export type ExecFn = (args: string[], options: ExecOptions) => Promise<ExecResult>;
21
23
  export declare const NOT_FOUND_EXIT = 127;
package/dist/exec.js CHANGED
@@ -100,14 +100,25 @@ function runUntil(binPath, args, env, options) {
100
100
  return;
101
101
  settled = true;
102
102
  clearTimeout(timer);
103
+ options.signal?.removeEventListener("abort", onAbort);
103
104
  child.kill();
104
105
  resolve(result);
105
106
  };
107
+ const onAbort = () => finish({
108
+ code: -1,
109
+ stdout,
110
+ stderr: `${binPath} cancelled`,
111
+ });
106
112
  const timer = setTimeout(() => finish({
107
113
  code: -1,
108
114
  stdout,
109
115
  stderr: `${binPath} timed out after ${options.timeoutMs}ms`,
110
116
  }), options.timeoutMs);
117
+ if (options.signal?.aborted) {
118
+ onAbort();
119
+ return;
120
+ }
121
+ options.signal?.addEventListener("abort", onAbort, { once: true });
111
122
  child.on("error", (err) => {
112
123
  const missing = err.code === "ENOENT" || err.code === "ENOTDIR";
113
124
  finish({
@@ -138,6 +149,7 @@ function runToExit(binPath, args, env, options) {
138
149
  cwd: options.cwd,
139
150
  maxBuffer: 8 * 1024 * 1024,
140
151
  windowsHide: true,
152
+ signal: options.signal,
141
153
  env: options.env ? { ...env, ...options.env } : env,
142
154
  }, (error, stdout, stderr) => {
143
155
  const failure = error;
@@ -151,11 +163,15 @@ function runToExit(binPath, args, env, options) {
151
163
  return;
152
164
  }
153
165
  if (failure &&
154
- (failure.killed === true || typeof failure.signal === "string")) {
166
+ (failure.killed === true ||
167
+ typeof failure.signal === "string" ||
168
+ failure.name === "AbortError")) {
155
169
  resolve({
156
170
  code: -1,
157
171
  stdout: String(stdout ?? ""),
158
- stderr: `${binPath} timed out after ${options.timeoutMs}ms`,
172
+ stderr: failure.name === "AbortError"
173
+ ? `${binPath} cancelled`
174
+ : `${binPath} timed out after ${options.timeoutMs}ms`,
159
175
  });
160
176
  return;
161
177
  }
package/dist/index.d.ts CHANGED
@@ -4,9 +4,9 @@ export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeL
4
4
  export { expandHomePath, resolveZellijBinary, sanitizeZellijEnv, type ZellijExecFn, type ZellijExecResult, } from "./zellij/binary.js";
5
5
  export { normalizePaneId, parsePaneList, resolvePane, type ZellijPane, } from "./zellij/panes.js";
6
6
  export { parseSessionList, resolveSelfPaneId, type ZellijSessionResolve, } from "./zellij/session.js";
7
- export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, } from "./zellij/args.js";
8
- export { busPluginUrl, busToPanes, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
9
- export { busSnapshot, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
7
+ export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, type LaunchPluginInput, type NewPaneInput, type NewTabInput, type PaneDirection, type PipeInput, type ScrollbackRequest, type WaitRequest, } from "./zellij/args.js";
8
+ export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, type BusMarker, type BusPane, type BusScrollback, type BusScrollbackPane, type BusSnapshot, } from "./zellij/bus.js";
9
+ export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, type BusPlan, } from "./ops/bus.js";
10
10
  export { createZellijClient, type ZellijClient, type ZellijClientOptions, } from "./zellij/client.js";
11
11
  export { createStateStore, defaultStateDir, type LogEntry, type SignalChannel, type StateStore, type StateStoreOptions, } from "./state.js";
12
12
  export { dispatchZswarm } from "./ops/dispatch.js";
@@ -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, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.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
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
@@ -4,9 +4,9 @@ export { createGitClient, defaultWorktreeRoot, normalizeRepoPath, parseWorktreeL
4
4
  export { expandHomePath, resolveZellijBinary, sanitizeZellijEnv, } from "./zellij/binary.js";
5
5
  export { normalizePaneId, parsePaneList, resolvePane, } from "./zellij/panes.js";
6
6
  export { parseSessionList, resolveSelfPaneId, } from "./zellij/session.js";
7
- export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, } from "./zellij/args.js";
8
- export { busPluginUrl, busToPanes, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
9
- export { busSnapshot, planBus, resetBusCache, } from "./ops/bus.js";
7
+ export { buildClosePaneArgs, buildDumpArgs, buildLaunchPluginArgs, buildListPanesArgs, buildNewPaneArgs, buildNewTabArgs, buildPasteArgs, buildPipeArgs, buildSendEnterArgs, buildSendKeysArgs, buildWriteCharsArgs, scrollbackPayload, waitPayload, } from "./zellij/args.js";
8
+ export { busPluginUrl, busToPanes, isBusPluginPane, nextConfigKey, parseBusReply, parseScrollbackReply, resolveBusPlugin, scrollbackToScreen, BUS_PIPE_NAME, DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, } from "./zellij/bus.js";
9
+ export { busSnapshot, closeBusPluginPanes, planBus, resetBusCache, } from "./ops/bus.js";
10
10
  export { createZellijClient, } from "./zellij/client.js";
11
11
  export { createStateStore, defaultStateDir, } from "./state.js";
12
12
  export { dispatchZswarm } from "./ops/dispatch.js";
@@ -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, isLoopbackHost, parseListenAddress, SERVE_CALL_TIMEOUT_CAP_MS, SERVE_MAX_REQUEST_BYTES, SERVE_TASK_NAME, serveCallTimeout, serveChildEnv, serveLogonCommand, startServe, uninstallServeLogon, } from "./ops/serve.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
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";
package/dist/ops/bus.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Policy } from "../policy.js";
1
+ import { type Policy } from "../policy.js";
2
2
  import type { StateStore } from "../state.js";
3
3
  import { type BusChanged, type BusSnapshot, type BusWait } from "../zellij/bus.js";
4
4
  import type { WaitRequest } from "../zellij/args.js";
@@ -15,15 +15,16 @@ export type BusPlan = {
15
15
  };
16
16
  /** Tests share a module instance; this drops what a previous case cached. */
17
17
  export declare function resetBusCache(): void;
18
- export declare function planBus(client: ZellijClient, state: StateStore, env?: NodeJS.ProcessEnv): BusPlan;
18
+ export declare function planBus(client: ZellijClient, state: StateStore, env?: NodeJS.ProcessEnv, session?: string): BusPlan;
19
19
  /**
20
20
  * One pipe, one answer — or null, and the caller polls instead.
21
21
  *
22
- * Two things go wrong in practice. A stale instance under the same key eats the
23
- * message and replies with nothing, so each attempt rotates to a new key. And a
24
- * freshly launched instance replies before Zellij has pushed it a manifest, so
25
- * a not-ready answer is retried once rather than reported as an empty session.
26
- * If it is still cold after that, one same-title rename nudges Zellij to push.
22
+ * A freshly launched instance replies before Zellij has pushed it a manifest,
23
+ * so a not-ready answer is retried once rather than reported as an empty
24
+ * session. If it is still cold after that, one same-title rename nudges Zellij
25
+ * to push. A silent pipe used to rotate `instance=` keys, and `zellij pipe
26
+ * --plugin` loads a new copy for each key — that is how a dead bus became
27
+ * dozens of WASM panes. One remembered key, then polling.
27
28
  */
28
29
  export declare function busSnapshot(client: ZellijClient, state: StateStore, session: string, clock: Clock, env?: NodeJS.ProcessEnv, payload?: string): Promise<{
29
30
  snapshot: BusSnapshot;
@@ -56,5 +57,7 @@ export declare function busWait(client: ZellijClient, state: StateStore, session
56
57
  * relative to the previous call rather than a fixed window.
57
58
  */
58
59
  export declare function busChanged(client: ZellijClient, state: StateStore, session: string, paneIds: string[], env?: NodeJS.ProcessEnv): Promise<BusChanged | null>;
60
+ /** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
61
+ export declare function closeBusPluginPanes(client: ZellijClient, session: string, pluginPath: string | null): Promise<string[]>;
59
62
  /** Report on the bus, install it, or forget it. */
60
63
  export declare function busOp(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, env?: NodeJS.ProcessEnv, policy?: Policy): Promise<OpsResult>;
package/dist/ops/bus.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
- import { DEFAULT_BUS_KEY, busPluginUrl, nextConfigKey, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, resolveBusPlugin, scrollbackToScreen, } from "../zellij/bus.js";
3
+ import { loadPolicy } from "../policy.js";
4
+ import { DEFAULT_BUS_KEY, busPluginUrl, isBusPluginPane, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, resolveBusPlugin, scrollbackToScreen, } from "../zellij/bus.js";
4
5
  import { isTrue } from "./util.js";
5
6
  /**
6
7
  * Whether to ask the event-bus plugin, and what to do when it does not answer.
@@ -9,10 +10,14 @@ import { isTrue } from "./util.js";
9
10
  * prompt, because a pipe to a plugin that was never approved costs a process
10
11
  * and a timeout on every call and then falls back anyway.
11
12
  */
12
- /** Config keys to try before giving up; each one is a fresh pipe destination. */
13
- const MAX_KEY_ATTEMPTS = 3;
14
13
  /** A just-launched instance answers before Zellij has pushed it anything. */
15
14
  const COLD_RETRY_MS = 200;
15
+ /**
16
+ * A marker written this recently with no live pane is an in-flight install,
17
+ * not a closed pane. Relaunching it is how concurrent agents used to stack
18
+ * copies.
19
+ */
20
+ const INSTALL_IN_FLIGHT_MS = 10_000;
16
21
  /** Set once a pipe has failed, so one dead plugin costs one timeout, not many. */
17
22
  let processDisabled = null;
18
23
  /** The key that actually answered, per session, for the life of this process. */
@@ -30,8 +35,8 @@ function negative(raw) {
30
35
  const v = (raw ?? "").trim().toLowerCase();
31
36
  return v === "0" || v === "false" || v === "no" || v === "off";
32
37
  }
33
- export function planBus(client, state, env = process.env) {
34
- const marker = state.readBus();
38
+ export function planBus(client, state, env = process.env, session = "") {
39
+ const marker = session ? state.readBus(session) : null;
35
40
  // A marker can outlive the wasm it names — an upgrade renames the artifact,
36
41
  // so a remembered path that no longer exists must not shadow the current one.
37
42
  const remembered = marker && existsSync(marker.plugin) ? marker : null;
@@ -75,7 +80,9 @@ async function askOnce(client, session, url, configKey, payload) {
75
80
  * blind (ready:false, paneUpdates:0) until something moves. Renaming a pane
76
81
  * to the title it already has is an invisible change that forces the push.
77
82
  */
78
- async function nudgeManifest(client, session) {
83
+ async function nudgeManifest(client, session, env) {
84
+ if (loadPolicy(env).readOnly)
85
+ return;
79
86
  const panes = await client.listPanes(session);
80
87
  const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
81
88
  if (!pane)
@@ -85,52 +92,40 @@ async function nudgeManifest(client, session) {
85
92
  /**
86
93
  * One pipe, one answer — or null, and the caller polls instead.
87
94
  *
88
- * Two things go wrong in practice. A stale instance under the same key eats the
89
- * message and replies with nothing, so each attempt rotates to a new key. And a
90
- * freshly launched instance replies before Zellij has pushed it a manifest, so
91
- * a not-ready answer is retried once rather than reported as an empty session.
92
- * If it is still cold after that, one same-title rename nudges Zellij to push.
95
+ * A freshly launched instance replies before Zellij has pushed it a manifest,
96
+ * so a not-ready answer is retried once rather than reported as an empty
97
+ * session. If it is still cold after that, one same-title rename nudges Zellij
98
+ * to push. A silent pipe used to rotate `instance=` keys, and `zellij pipe
99
+ * --plugin` loads a new copy for each key — that is how a dead bus became
100
+ * dozens of WASM panes. One remembered key, then polling.
93
101
  */
94
102
  export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status") {
95
- const plan = planBus(client, state, env);
103
+ const plan = planBus(client, state, env, session);
96
104
  const pluginPath = plan.plugin;
97
105
  if (!plan.enabled || !plan.url || !pluginPath)
98
106
  return null;
99
- let configKey = answeredWith.get(session) ?? plan.configKey;
100
- let nudged = false;
101
- for (let attempt = 0; attempt < MAX_KEY_ATTEMPTS; attempt++) {
102
- let snapshot = await askOnce(client, session, plan.url, configKey, payload);
103
- if (snapshot && !snapshot.ready) {
104
- await clock.sleep(COLD_RETRY_MS);
107
+ const configKey = answeredWith.get(session) ?? plan.configKey;
108
+ let snapshot = await askOnce(client, session, plan.url, configKey, payload);
109
+ if (snapshot && !snapshot.ready) {
110
+ await clock.sleep(COLD_RETRY_MS);
111
+ snapshot =
112
+ (await askOnce(client, session, plan.url, configKey, payload)) ??
113
+ snapshot;
114
+ }
115
+ if (snapshot && !snapshot.ready) {
116
+ try {
117
+ await nudgeManifest(client, session, env);
105
118
  snapshot =
106
119
  (await askOnce(client, session, plan.url, configKey, payload)) ??
107
120
  snapshot;
108
121
  }
109
- if (snapshot && !snapshot.ready && !nudged) {
110
- nudged = true;
111
- try {
112
- await nudgeManifest(client, session);
113
- snapshot =
114
- (await askOnce(client, session, plan.url, configKey, payload)) ??
115
- snapshot;
116
- }
117
- catch {
118
- // A failed nudge must not fail the op — the caller polls instead.
119
- }
120
- }
121
- if (snapshot) {
122
- answeredWith.set(session, configKey);
123
- if (plan.installed && configKey !== plan.configKey) {
124
- // Remember the rotation, so the next process starts on a live key.
125
- state.writeBus({
126
- plugin: pluginPath,
127
- configKey,
128
- installedAt: clock.now(),
129
- });
130
- }
131
- return { snapshot, configKey };
122
+ catch {
123
+ // A failed nudge must not fail the op — the caller polls instead.
132
124
  }
133
- configKey = nextConfigKey(configKey);
125
+ }
126
+ if (snapshot) {
127
+ answeredWith.set(session, configKey);
128
+ return { snapshot, configKey };
134
129
  }
135
130
  processDisabled = "plugin did not answer; using zellij polling for this run";
136
131
  return null;
@@ -149,7 +144,7 @@ export async function busSnapshot(client, state, session, clock, env = process.e
149
144
  export async function busScreens(client, state, session, paneIds, clock, env = process.env) {
150
145
  if (paneIds.length < 2)
151
146
  return null;
152
- const plan = planBus(client, state, env);
147
+ const plan = planBus(client, state, env, session);
153
148
  if (!plan.enabled || !plan.url)
154
149
  return null;
155
150
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -179,7 +174,7 @@ export async function busScreens(client, state, session, paneIds, clock, env = p
179
174
  * instance, no reply — and the caller runs its own loop.
180
175
  */
181
176
  export async function busWait(client, state, session, request, env = process.env) {
182
- const plan = planBus(client, state, env);
177
+ const plan = planBus(client, state, env, session);
183
178
  if (!plan.enabled || !plan.url)
184
179
  return null;
185
180
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -202,7 +197,7 @@ export async function busWait(client, state, session, request, env = process.env
202
197
  export async function busChanged(client, state, session, paneIds, env = process.env) {
203
198
  if (paneIds.length === 0)
204
199
  return null;
205
- const plan = planBus(client, state, env);
200
+ const plan = planBus(client, state, env, session);
206
201
  if (!plan.enabled || !plan.url)
207
202
  return null;
208
203
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -217,14 +212,63 @@ export async function busChanged(client, state, session, paneIds, env = process.
217
212
  return null;
218
213
  return parsed.panes.length === paneIds.length ? parsed : null;
219
214
  }
215
+ function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
216
+ return {
217
+ ok: true,
218
+ data: {
219
+ session,
220
+ installed: true,
221
+ plugin,
222
+ url: busPluginUrl(plugin),
223
+ configKey,
224
+ ready,
225
+ panes,
226
+ ...extra,
227
+ },
228
+ };
229
+ }
230
+ async function listBusPluginPanes(client, session, pluginPath) {
231
+ const panes = await client.listPanes(session);
232
+ return panes.filter((pane) => isBusPluginPane(pane, pluginPath));
233
+ }
234
+ /** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
235
+ export async function closeBusPluginPanes(client, session, pluginPath) {
236
+ const closed = [];
237
+ const panes = await listBusPluginPanes(client, session, pluginPath);
238
+ for (const pane of panes) {
239
+ try {
240
+ await client.closePane({ session, paneId: pane.id });
241
+ closed.push(pane.id);
242
+ }
243
+ catch {
244
+ // A pane that vanished between list and close is the desired end state.
245
+ }
246
+ }
247
+ return closed;
248
+ }
220
249
  /** Report on the bus, install it, or forget it. */
221
250
  export async function busOp(client, state, args, clock, env = process.env, policy) {
251
+ const sessionArg = typeof args.session === "string" ? args.session : undefined;
222
252
  if (isTrue(args.clear)) {
223
- state.clearBus();
253
+ let session = null;
254
+ let closed = [];
255
+ try {
256
+ session = (await client.resolveSession(sessionArg)).session;
257
+ }
258
+ catch {
259
+ session = sessionArg?.trim() || null;
260
+ }
261
+ if (session) {
262
+ closed = await closeBusPluginPanes(client, session, resolveBusPlugin(env) ?? state.readBus(session)?.plugin ?? null);
263
+ state.clearBus(session);
264
+ }
265
+ else {
266
+ state.clearBus();
267
+ }
224
268
  resetBusCache();
225
- return { ok: true, data: { installed: false, cleared: true } };
269
+ return { ok: true, data: { installed: false, cleared: true, closed } };
226
270
  }
227
- const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
271
+ const { session } = await client.resolveSession(sessionArg);
228
272
  if (isTrue(args.install)) {
229
273
  // Installing opens a pane, which a read-only crew is not allowed to do.
230
274
  if (policy?.readOnly) {
@@ -234,32 +278,31 @@ export async function busOp(client, state, args, clock, env = process.env, polic
234
278
  if (!plugin) {
235
279
  throw new ZellijError("bus_missing", "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN");
236
280
  }
237
- const previous = state.readBus();
281
+ const previous = state.readBus(session);
238
282
  const force = isTrue(args.force);
239
- // Launching again would add a second instance, so an install that is
240
- // already answering is a no-op unless the caller asked for a fresh one.
241
283
  if (previous && !force) {
242
284
  const live = await busSnapshot(client, state, session, clock, env);
243
285
  if (live?.snapshot.ready) {
244
- return {
245
- ok: true,
246
- data: {
247
- session,
248
- installed: true,
249
- plugin: previous.plugin,
250
- url: busPluginUrl(previous.plugin),
251
- configKey: live.configKey,
252
- ready: true,
253
- panes: live.snapshot.panes.length,
254
- note: "already installed; --force reloads under a fresh key",
255
- },
256
- };
286
+ return installedReply(session, previous.plugin, live.configKey, true, live.snapshot.panes.length, { note: "already installed" });
287
+ }
288
+ const existing = await listBusPluginPanes(client, session, plugin);
289
+ if (existing.length > 0) {
290
+ return installedReply(session, previous.plugin, previous.configKey, false, null, {
291
+ pane: existing[0]?.id ?? null,
292
+ note: "already installed but not answering; approve the permission prompt in the existing pane — do not --force unless you intend to replace it",
293
+ });
294
+ }
295
+ if (clock.now() - previous.installedAt < INSTALL_IN_FLIGHT_MS) {
296
+ return installedReply(session, previous.plugin, previous.configKey, false, null, {
297
+ note: "install already in flight; wait for the permission prompt rather than launching another pane",
298
+ });
257
299
  }
258
300
  }
259
- // A reload needs a key Zellij has not already bound an instance to.
260
- const configKey = force && previous
261
- ? nextConfigKey(previous.configKey)
262
- : (previous?.configKey ?? DEFAULT_BUS_KEY);
301
+ // Marker first so a concurrent install sees an in-flight write and
302
+ // stops. Then reap orphans so a reload cannot stack on the old copies.
303
+ const configKey = previous?.configKey ?? DEFAULT_BUS_KEY;
304
+ state.writeBus(session, { plugin, configKey, installedAt: clock.now() });
305
+ const closed = await closeBusPluginPanes(client, session, plugin);
263
306
  const url = busPluginUrl(plugin);
264
307
  const launched = await client.launchPlugin({
265
308
  session,
@@ -268,27 +311,17 @@ export async function busOp(client, state, args, clock, env = process.env, polic
268
311
  floating: true,
269
312
  skipCache: force,
270
313
  });
271
- state.writeBus({ plugin, configKey, installedAt: clock.now() });
272
314
  resetBusCache();
273
315
  const probe = await busSnapshot(client, state, session, clock, env);
274
- return {
275
- ok: true,
276
- data: {
277
- session,
278
- installed: true,
279
- plugin,
280
- url,
281
- configKey,
282
- pane: launched.paneId,
283
- ready: probe?.snapshot.ready ?? false,
284
- panes: probe?.snapshot.panes.length ?? null,
285
- note: probe
286
- ? "bus answering; list and status use it now, and this pane can be closed"
287
- : "approve the plugin's permission prompt in the new pane, then re-run",
288
- },
289
- };
316
+ return installedReply(session, plugin, configKey, probe?.snapshot.ready ?? false, probe?.snapshot.panes.length ?? null, {
317
+ pane: launched.paneId,
318
+ closed,
319
+ note: probe
320
+ ? "bus answering; keep this floating pane open — closing it unloads the bus"
321
+ : "approve the plugin's permission prompt in the new pane, then re-run without --install",
322
+ });
290
323
  }
291
- const plan = planBus(client, state, env);
324
+ const plan = planBus(client, state, env, session);
292
325
  const probe = plan.enabled
293
326
  ? await busSnapshot(client, state, session, clock, env)
294
327
  : null;
@@ -40,9 +40,9 @@ export declare function withSenderLabel(args: Record<string, unknown>, opts?: {
40
40
  selfTitle?: string | null;
41
41
  }): Record<string, unknown>;
42
42
  /**
43
- * Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
44
- * leave `from` unset when it would default, so the Zellij-side process can
45
- * still fill in its own pane title.
43
+ * Before forwarding over `ZSWARM_SERVE`, stamp a caller-chosen label. Leaving
44
+ * `from` unset used to let the Zellij-side process fill in its pane title —
45
+ * which is the serve daemon when it runs in a pane, not the remote sender.
46
46
  */
47
47
  export declare function attachKnownSender(args: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown>;
48
48
  export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
@@ -45,19 +45,16 @@ export function withSenderLabel(args, opts = {}) {
45
45
  return { ...args, from: senderLabel(args, opts) };
46
46
  }
47
47
  /**
48
- * Before forwarding over `ZSWARM_SERVE`, keep a caller-chosen label and
49
- * leave `from` unset when it would default, so the Zellij-side process can
50
- * still fill in its own pane title.
48
+ * Before forwarding over `ZSWARM_SERVE`, stamp a caller-chosen label. Leaving
49
+ * `from` unset used to let the Zellij-side process fill in its pane title —
50
+ * which is the serve daemon when it runs in a pane, not the remote sender.
51
51
  */
52
52
  export function attachKnownSender(args, env) {
53
53
  const op = String(args.op ?? "");
54
54
  if (op !== "send" && op !== "broadcast")
55
55
  return args;
56
56
  const sender = resolveSender(args, { env });
57
- if (sender.source === "arg" || sender.source === "env") {
58
- return { ...args, from: sender.label };
59
- }
60
- return args;
57
+ return { ...args, from: sender.label };
61
58
  }
62
59
  export function bodyText(client, args, body) {
63
60
  return isTrue(args.raw) ? body : client.formatPeerMessage(senderLabel(args), body);
@@ -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, optionalString, paneViewBus, paneViewFull, paneViewSlim, truncateDumpText, } from "./util.js";
20
+ import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, 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";
@@ -85,10 +85,23 @@ async function resolveTarget(client, args, state, clock, env, policy, op) {
85
85
  export async function dispatchZswarm(args, injected, deps = {}) {
86
86
  const op = String(args.op ?? "");
87
87
  const verbose = isVerbose(args);
88
+ const signal = deps.signal;
89
+ throwIfAborted(signal);
88
90
  const clock = {
89
91
  now: deps.now ?? (() => Date.now()),
90
92
  sleep: deps.sleep ??
91
- ((ms) => new Promise((r) => setTimeout(r, ms))),
93
+ ((ms) => new Promise((resolve, reject) => {
94
+ if (signal?.aborted) {
95
+ reject(new ZellijError("cancelled", "operation cancelled"));
96
+ return;
97
+ }
98
+ const timer = setTimeout(resolve, ms);
99
+ const onAbort = () => {
100
+ clearTimeout(timer);
101
+ reject(new ZellijError("cancelled", "operation cancelled"));
102
+ };
103
+ signal?.addEventListener("abort", onAbort, { once: true });
104
+ })),
92
105
  };
93
106
  // Only the worktree ops need git, so the client is built on demand.
94
107
  let gitClient = deps.git ?? null;
@@ -121,7 +134,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
121
134
  if (!injected && env.ZSWARM_SERVE?.trim()) {
122
135
  return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
123
136
  }
124
- const client = injected ?? createZellijClient({ env: deps.env });
137
+ const client = injected ?? createZellijClient({ env: deps.env, signal });
125
138
  switch (op) {
126
139
  case "sessions": {
127
140
  const sessions = await client.listSessions();
@@ -278,15 +291,14 @@ export async function dispatchZswarm(args, injected, deps = {}) {
278
291
  // no engine — and then this is the loop that runs.
279
292
  const viaBus = isTrue(args.regex)
280
293
  ? undefined
281
- : () => busWait(client, state(), target.session, {
294
+ : (timing) => busWait(client, state(), target.session, {
282
295
  pane: target.pane.id,
283
296
  for: waitMode(args),
284
297
  match: typeof args.match === "string" ? args.match : null,
285
298
  ignoreCase: isTrue(args.ignoreCase),
286
- idleMs: Number(args.idleMs ?? 2000),
287
- timeoutMs: Number(args.timeoutMs ?? 60_000),
299
+ ...timing,
288
300
  }, deps.env);
289
- return await waitForPane(client, target, args, clock, viaBus);
301
+ return await waitForPane(client, target, args, clock, viaBus, deps.signal);
290
302
  }
291
303
  case "status": {
292
304
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
@@ -36,6 +36,7 @@ export declare function parseServeTarget(raw: string): {
36
36
  port: number;
37
37
  };
38
38
  export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
39
+ export declare function redactServeSecret(command: string, token: string): string;
39
40
  export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
40
41
  export declare function installServeLogon(input: {
41
42
  listen?: string;
package/dist/ops/serve.js CHANGED
@@ -252,6 +252,12 @@ export function callServe(target, args, timeoutMs = 15_000, token) {
252
252
  });
253
253
  });
254
254
  }
255
+ export function redactServeSecret(command, token) {
256
+ const secret = token.trim();
257
+ if (!secret)
258
+ return command;
259
+ return command.split(secret).join("***");
260
+ }
255
261
  export function serveLogonCommand(execPath, scriptPath, listen, token) {
256
262
  const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
257
263
  const secret = token?.trim();
@@ -323,7 +329,7 @@ export async function installServeLogon(input) {
323
329
  if (result.code !== 0) {
324
330
  throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
325
331
  }
326
- return { task: SERVE_TASK_NAME, listen: label, command };
332
+ return { task: SERVE_TASK_NAME, listen: label, command: redactServeSecret(command, token) };
327
333
  }
328
334
  export async function uninstallServeLogon(input = {}) {
329
335
  if ((input.platform ?? process.platform) !== "win32") {
@@ -19,6 +19,8 @@ export type DispatchDeps = {
19
19
  state?: StateStore;
20
20
  policy?: Policy;
21
21
  env?: NodeJS.ProcessEnv;
22
+ /** MCP cancellation; aborted waits stop instead of running to timeout. */
23
+ signal?: AbortSignal;
22
24
  };
23
25
  export type Clock = {
24
26
  now: () => number;
@@ -45,4 +45,5 @@ export declare function numberArg(args: Record<string, unknown>, key: string, fa
45
45
  min: number;
46
46
  max: number;
47
47
  }): number;
48
+ export declare function throwIfAborted(signal?: AbortSignal): void;
48
49
  export declare function dumpMaxChars(args: Record<string, unknown>, fallback?: number): number;
package/dist/ops/util.js CHANGED
@@ -73,6 +73,11 @@ export function numberArg(args, key, fallback, limits) {
73
73
  }
74
74
  return Math.min(limits.max, Math.max(limits.min, Math.floor(n)));
75
75
  }
76
+ export function throwIfAborted(signal) {
77
+ if (signal?.aborted) {
78
+ throw new ZellijError("cancelled", "operation cancelled");
79
+ }
80
+ }
76
81
  export function dumpMaxChars(args, fallback = DEFAULT_DUMP_MAX_CHARS) {
77
82
  if (args.max === undefined || args.max === null || args.max === "") {
78
83
  return fallback;
@@ -5,6 +5,13 @@ import type { Clock, OpsResult } from "./types.js";
5
5
  export declare function buildMatcher(args: Record<string, unknown>): ((text: string) => boolean) | null;
6
6
  /** Held-pipe wait answer, shaped by `parseWaitReply` in `zellij/bus.ts`. */
7
7
  export type { BusWait };
8
+ /** Clamped timing the bus path must use — never the raw request numbers. */
9
+ export type WaitBusTiming = {
10
+ idleMs: number;
11
+ timeoutMs: number;
12
+ pollMs: number;
13
+ full: boolean;
14
+ };
8
15
  /**
9
16
  * Poll a pane's screen until it goes quiet, prints a match, or the deadline
10
17
  * passes — so callers stop re-dumping in a loop of their own.
@@ -16,4 +23,4 @@ export type { BusWait };
16
23
  export declare function waitForPane(client: ZellijClient, target: {
17
24
  session: string;
18
25
  pane: ZellijPane;
19
- }, args: Record<string, unknown>, clock: Clock, waitViaBus?: () => Promise<BusWait | null>): Promise<OpsResult>;
26
+ }, args: Record<string, unknown>, clock: Clock, waitViaBus?: (timing: WaitBusTiming) => Promise<BusWait | null>, signal?: AbortSignal): Promise<OpsResult>;
package/dist/ops/wait.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ZellijError } from "../errors.js";
2
- import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, truncateDumpText, } from "./util.js";
2
+ import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, truncateDumpText, } from "./util.js";
3
3
  const WAIT_DEFAULTS = {
4
4
  idleMs: 2_000,
5
5
  pollMs: 150,
@@ -56,7 +56,7 @@ function waitResult(reason, ctx) {
56
56
  * Null (plugin missing or silent) falls back to dump-screen polling. The
57
57
  * pipe's own timeout belongs to the transport; this op does not cap it.
58
58
  */
59
- export async function waitForPane(client, target, args, clock, waitViaBus) {
59
+ export async function waitForPane(client, target, args, clock, waitViaBus, signal) {
60
60
  const { session, pane } = target;
61
61
  const matcher = buildMatcher(args);
62
62
  const requested = optionalString(args.for) ?? (matcher ? "match" : "idle");
@@ -72,8 +72,11 @@ export async function waitForPane(client, target, args, clock, waitViaBus) {
72
72
  const pollMs = numberArg(args, "pollMs", WAIT_DEFAULTS.pollMs, WAIT_LIMITS.pollMs);
73
73
  const timeoutMs = numberArg(args, "timeoutMs", WAIT_DEFAULTS.timeoutMs, WAIT_LIMITS.timeoutMs);
74
74
  const started = clock.now();
75
+ const full = isTrue(args.full);
75
76
  if (waitViaBus) {
76
- const reply = await waitViaBus();
77
+ throwIfAborted(signal);
78
+ const reply = await waitViaBus({ idleMs, timeoutMs, pollMs, full });
79
+ throwIfAborted(signal);
77
80
  // `gone` is filtered upstream: a pane that vanished mid-wait is reported by
78
81
  // the polling path, which has the error message for it.
79
82
  if (reply && reply.reason !== "gone") {
@@ -96,6 +99,7 @@ export async function waitForPane(client, target, args, clock, waitViaBus) {
96
99
  let polls = 0;
97
100
  let changes = 0;
98
101
  for (;;) {
102
+ throwIfAborted(signal);
99
103
  const dumped = await client.dumpPane({
100
104
  session,
101
105
  paneId: pane.id,
package/dist/schema.js CHANGED
@@ -42,6 +42,9 @@ export const TARGET_OPS = [
42
42
  "keys",
43
43
  "interrupt",
44
44
  "close",
45
+ "rename",
46
+ "focus",
47
+ "stack",
45
48
  ];
46
49
  export const PARAMS = [
47
50
  {
@@ -382,7 +385,7 @@ export const PARAMS = [
382
385
  name: "force",
383
386
  type: "boolean",
384
387
  flags: ["--force"],
385
- description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: reinstall under a fresh key",
388
+ description: "send/keys: write to a pane whose command has exited; unworktree: remove a busy or dirty worktree; bus: close orphan bus panes and reload this session's plugin",
386
389
  },
387
390
  {
388
391
  name: "listen",
@@ -447,7 +450,7 @@ export function cliUsage() {
447
450
  const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
448
451
  lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
449
452
  }
450
- lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once, then list and status use it now.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE (+ ZSWARM_SERVE_TOKEN off loopback).", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN", "");
453
+ 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 per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "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). Serve binds loopback only and always requires a token.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN", "");
451
454
  return lines.join("\n");
452
455
  }
453
456
  /** Turn argv (without the op) into dispatch args, driven by PARAMS. */
package/dist/state.d.ts CHANGED
@@ -19,9 +19,10 @@ export type SignalChannel = {
19
19
  last: string | null;
20
20
  };
21
21
  /**
22
- * Written once by `bus --install`, after the plugin's permission prompt has
23
- * been answered. Its presence is what lets later runs try the fast path without
24
- * every cold `zswarm status` paying for a pipe that was never going to answer.
22
+ * Written by `bus --install` per Zellij session, after the plugin's permission
23
+ * prompt has been answered. Its presence is what lets later runs try the fast
24
+ * path without every cold `zswarm status` paying for a pipe that was never
25
+ * going to answer.
25
26
  */
26
27
  export type BusMarkerRecord = {
27
28
  plugin: string;
@@ -44,9 +45,9 @@ export declare function createStateStore(options?: StateStoreOptions): {
44
45
  readCursor: (key: string) => string | null;
45
46
  writeCursor: (key: string, text: string) => void;
46
47
  clearCursor: (key: string) => void;
47
- readBus: () => BusMarkerRecord | null;
48
- writeBus: (marker: BusMarkerRecord) => void;
49
- clearBus: () => void;
48
+ readBus: (session: string) => BusMarkerRecord | null;
49
+ writeBus: (session: string, marker: BusMarkerRecord) => void;
50
+ clearBus: (session?: string) => void;
50
51
  reset: () => void;
51
52
  };
52
53
  export type StateStore = ReturnType<typeof createStateStore>;
package/dist/state.js CHANGED
@@ -10,6 +10,8 @@ const BUS_FILE = "bus.json";
10
10
  /** Keeps the log bounded without needing a rotation daemon. */
11
11
  const LOG_TAIL_BYTES = 512 * 1024;
12
12
  const LOCK_WAIT_MS = 5_000;
13
+ /** A live pid older than this is treated as a recycle of a crashed holder. */
14
+ const LOCK_STALE_MS = 30_000;
13
15
  function sleepSync(ms) {
14
16
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
15
17
  }
@@ -102,16 +104,24 @@ export function createStateStore(options = {}) {
102
104
  }
103
105
  }
104
106
  /**
105
- * Dead pid → steal now. Empty leftover from older writers (wx with no owner
106
- * bytes) → steal once mtime is older than the wait, so an in-flight create is
107
- * not yanked out from under the holder.
107
+ * Dead pid → steal now. This process's own leftover (unlink failed, or a
108
+ * non-reentrant re-entry) → steal now. A live pid whose `at` is older than
109
+ * LOCK_STALE_MS is a recycled pid, not a holder still inside fn().
110
+ * Empty leftover from older writers (wx with no owner bytes) → steal once
111
+ * mtime is older than the wait, so an in-flight create is not yanked out
112
+ * from under the holder.
108
113
  * Owner is read once: a second read can see a pid that appeared after an
109
114
  * empty snapshot and would steal a live lock.
110
115
  */
111
116
  function lockIsStale(lockPath) {
112
117
  const owner = readLockOwner(lockPath);
113
- if (owner)
114
- return !pidAlive(owner.pid);
118
+ if (owner) {
119
+ if (owner.pid === process.pid)
120
+ return true;
121
+ if (!pidAlive(owner.pid))
122
+ return true;
123
+ return Date.now() - owner.at >= LOCK_STALE_MS;
124
+ }
115
125
  try {
116
126
  return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
117
127
  }
@@ -162,7 +172,7 @@ export function createStateStore(options = {}) {
162
172
  const code = err.code;
163
173
  if (!lockBusy(code))
164
174
  throw err;
165
- if (code === "EEXIST" && lockIsStale(lockPath)) {
175
+ if (lockIsStale(lockPath)) {
166
176
  unlinkLock(lockPath);
167
177
  continue;
168
178
  }
@@ -222,22 +232,72 @@ export function createStateStore(options = {}) {
222
232
  writeJson(CURSORS_FILE, all);
223
233
  });
224
234
  }
225
- function readBus() {
226
- const value = readJson(BUS_FILE, null);
227
- if (!value || typeof value.plugin !== "string" || !value.plugin)
235
+ function asMarker(value) {
236
+ if (!value || typeof value !== "object")
237
+ return null;
238
+ const rec = value;
239
+ if (typeof rec.plugin !== "string" || !rec.plugin)
240
+ return null;
241
+ if (typeof rec.configKey !== "string" || !rec.configKey)
228
242
  return null;
229
- return value;
243
+ return {
244
+ plugin: rec.plugin,
245
+ configKey: rec.configKey,
246
+ installedAt: typeof rec.installedAt === "number" ? rec.installedAt : 0,
247
+ };
230
248
  }
231
- function writeBus(marker) {
232
- writeJson(BUS_FILE, marker);
249
+ /**
250
+ * Current `{ sessions: { <name>: marker } }` plus the pre-0.1.6 flat file
251
+ * `{ plugin, configKey, installedAt }`, which any session may inherit until
252
+ * the next write namespaces it.
253
+ */
254
+ function readBusFile() {
255
+ const raw = readJson(BUS_FILE, null);
256
+ if (!raw || typeof raw !== "object")
257
+ return { sessions: {}, legacy: null };
258
+ const sessions = {};
259
+ if (raw.sessions && typeof raw.sessions === "object") {
260
+ for (const [name, marker] of Object.entries(raw.sessions)) {
261
+ const parsed = asMarker(marker);
262
+ if (parsed)
263
+ sessions[name] = parsed;
264
+ }
265
+ }
266
+ return { sessions, legacy: asMarker(raw) };
233
267
  }
234
- function clearBus() {
235
- try {
236
- rmSync(join(dir, BUS_FILE), { force: true });
268
+ function readBus(session) {
269
+ if (!session)
270
+ return null;
271
+ const { sessions, legacy } = readBusFile();
272
+ return sessions[session] ?? legacy;
273
+ }
274
+ function writeBus(session, marker) {
275
+ const { sessions } = readBusFile();
276
+ sessions[session] = marker;
277
+ writeJson(BUS_FILE, { sessions });
278
+ }
279
+ function clearBus(session) {
280
+ if (!session) {
281
+ try {
282
+ rmSync(join(dir, BUS_FILE), { force: true });
283
+ }
284
+ catch {
285
+ // Nothing to forget.
286
+ }
287
+ return;
237
288
  }
238
- catch {
239
- // Nothing to forget.
289
+ const { sessions } = readBusFile();
290
+ delete sessions[session];
291
+ if (Object.keys(sessions).length === 0) {
292
+ try {
293
+ rmSync(join(dir, BUS_FILE), { force: true });
294
+ }
295
+ catch {
296
+ // Nothing to forget.
297
+ }
298
+ return;
240
299
  }
300
+ writeJson(BUS_FILE, { sessions });
241
301
  }
242
302
  /** Test helper: drop everything this store wrote. */
243
303
  function reset() {
@@ -47,6 +47,8 @@ export type WaitRequest = {
47
47
  idleMs?: number;
48
48
  timeoutMs?: number;
49
49
  pollMs?: number;
50
+ /** Read scrollback above the viewport, matching dump --full. */
51
+ full?: boolean;
50
52
  };
51
53
  /**
52
54
  * `regex` is deliberately absent: the plugin has no regex engine, refuses such
@@ -35,6 +35,7 @@ export function waitPayload(req) {
35
35
  idleMs: req.idleMs ?? 2000,
36
36
  timeoutMs: req.timeoutMs ?? 60000,
37
37
  pollMs: req.pollMs ?? 50,
38
+ full: req.full === true,
38
39
  });
39
40
  }
40
41
  /** Screens plus "did this move since you last asked", so status needs no gap. */
@@ -67,6 +67,15 @@ export declare function resolveBusPlugin(env?: NodeJS.ProcessEnv): string | null
67
67
  export declare function busPluginUrl(pluginPath: string): string;
68
68
  /** zswarm-bus → zswarm-bus-2 → zswarm-bus-3: a fresh pipe destination each time. */
69
69
  export declare function nextConfigKey(key: string): string;
70
+ /**
71
+ * Plugin panes belonging to this bus — never tab-bar / status-bar / room.
72
+ * Titles are the `file:` URL (or the wasm basename) Zellij shows for the pane.
73
+ */
74
+ export declare function isBusPluginPane(pane: {
75
+ isPlugin?: boolean;
76
+ title?: string;
77
+ command?: string | null;
78
+ }, pluginPath?: string | null): boolean;
70
79
  /**
71
80
  * Pull the plugin's answer out of the pipe. Zellij interleaves its own notices
72
81
  * ("Action CliPipe did not complete within 1s timeout" shows up on successful
@@ -140,6 +140,26 @@ export function nextConfigKey(key) {
140
140
  return `${key}-2`;
141
141
  return `${match[1]}-${Number(match[2]) + 1}`;
142
142
  }
143
+ /**
144
+ * Plugin panes belonging to this bus — never tab-bar / status-bar / room.
145
+ * Titles are the `file:` URL (or the wasm basename) Zellij shows for the pane.
146
+ */
147
+ export function isBusPluginPane(pane, pluginPath) {
148
+ if (!pane.isPlugin)
149
+ return false;
150
+ const hay = `${pane.title ?? ""}\n${pane.command ?? ""}`
151
+ .replace(/\\/g, "/")
152
+ .toLowerCase();
153
+ if (hay.includes("zswarm-bus") || hay.includes("zswarm-events"))
154
+ return true;
155
+ if (!pluginPath)
156
+ return false;
157
+ const normalized = pluginPath.replace(/\\/g, "/").toLowerCase();
158
+ if (normalized && hay.includes(normalized))
159
+ return true;
160
+ const base = normalized.split("/").pop();
161
+ return Boolean(base && hay.includes(base));
162
+ }
143
163
  function toSnapshot(value) {
144
164
  if (value.ok !== true || !Array.isArray(value.panes))
145
165
  return null;
@@ -8,6 +8,7 @@ export type ZellijClientOptions = {
8
8
  zellijPath?: string;
9
9
  timeoutMs?: number;
10
10
  env?: NodeJS.ProcessEnv;
11
+ signal?: AbortSignal;
11
12
  };
12
13
  /** Thin, stateless wrapper over the `zellij` binary. */
13
14
  export declare function createZellijClient(options?: ZellijClientOptions): {
@@ -13,10 +13,11 @@ export function createZellijClient(options = {}) {
13
13
  const ssh = options.exec ? null : resolveSshTarget(env);
14
14
  const zellijPath = options.zellijPath ??
15
15
  (ssh ? `ssh://${ssh.host}/${ssh.remoteBin}` : resolveZellijBinary(env));
16
- const exec = options.exec ??
16
+ const rawExec = options.exec ??
17
17
  (ssh
18
18
  ? createSshExec(ssh, sanitizeZellijEnv(env))
19
19
  : defaultExec(zellijPath, env));
20
+ const exec = (args, opts) => rawExec(args, { ...opts, signal: opts.signal ?? options.signal });
20
21
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
21
22
  const selfPaneId = resolveSelfPaneId(env);
22
23
  async function run(args, label) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zswarm/core",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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.1"
28
+ "@zswarm/wasm": "0.1.2"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.json",