@zswarm/core 0.1.3 → 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";
@@ -16,4 +16,4 @@ export declare function selectTargets(client: ZellijClient, panes: ZellijPane[],
16
16
  skipped: Skipped[];
17
17
  };
18
18
  /** Send one body to many panes, reporting per-pane outcomes. */
19
- export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy): Promise<OpsResult>;
19
+ export declare function broadcast(client: ZellijClient, state: StateStore, args: Record<string, unknown>, clock: Clock, policy?: Policy, env?: NodeJS.ProcessEnv): Promise<OpsResult>;
@@ -1,6 +1,6 @@
1
1
  import { ZellijError } from "../errors.js";
2
2
  import { assertPaneAllowed } from "../policy.js";
3
- import { deliverTo } from "./delivery.js";
3
+ import { deliverTo, selfPaneTitle, withSenderLabel } from "./delivery.js";
4
4
  import { isTrue, optionalString } from "./util.js";
5
5
  /**
6
6
  * Pick the panes a broadcast should reach: an explicit comma list, a tab, or
@@ -65,13 +65,17 @@ export function selectTargets(client, panes, args) {
65
65
  return { targets, skipped };
66
66
  }
67
67
  /** Send one body to many panes, reporting per-pane outcomes. */
68
- export async function broadcast(client, state, args, clock, policy) {
68
+ export async function broadcast(client, state, args, clock, policy, env = process.env) {
69
69
  const body = String(args.body ?? args.text ?? "");
70
70
  if (!body.trim())
71
71
  throw new ZellijError("missing_body", "body required");
72
72
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
73
73
  const panes = await client.listPanes(session);
74
- const { targets: selected, skipped } = selectTargets(client, panes, args);
74
+ const labeled = withSenderLabel(args, {
75
+ env,
76
+ selfTitle: selfPaneTitle(client, panes),
77
+ });
78
+ const { targets: selected, skipped } = selectTargets(client, panes, labeled);
75
79
  // Policy narrows the selection rather than failing the whole broadcast.
76
80
  const targets = selected.filter((pane) => {
77
81
  if (!policy)
@@ -90,7 +94,7 @@ export async function broadcast(client, state, args, clock, policy) {
90
94
  }
91
95
  const results = [];
92
96
  for (const pane of targets) {
93
- results.push(await deliverTo(client, state, args, {
97
+ results.push(await deliverTo(client, state, labeled, {
94
98
  session,
95
99
  pane,
96
100
  body,
@@ -104,7 +108,7 @@ export async function broadcast(client, state, args, clock, policy) {
104
108
  ok: true,
105
109
  data: {
106
110
  session,
107
- from: (typeof args.from === "string" && args.from.trim()) || "swarm",
111
+ from: labeled.from,
108
112
  delivered: delivered.map((r) => r.to),
109
113
  failed: results.filter((r) => !r.ok),
110
114
  skipped,
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;
@@ -14,7 +14,37 @@ export type DeliveryResult = {
14
14
  };
15
15
  };
16
16
  export type SubmitMode = "auto" | "double-enter" | "none";
17
- export declare function senderLabel(args: Record<string, unknown>): string;
17
+ export type SenderSource = "arg" | "env" | "title" | "default";
18
+ export type SenderLabel = {
19
+ label: string;
20
+ source: SenderSource;
21
+ };
22
+ /** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
23
+ export declare function sanitizeSenderLabel(raw: string): string;
24
+ export declare function selfPaneTitle(client: Pick<ZellijClient, "selfPaneId">, panes: ZellijPane[]): string | null;
25
+ /**
26
+ * Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
27
+ * then the sending pane's title when we know that pane, else `swarm`.
28
+ */
29
+ export declare function resolveSender(args: Record<string, unknown>, opts?: {
30
+ env?: NodeJS.ProcessEnv;
31
+ selfTitle?: string | null;
32
+ }): SenderLabel;
33
+ export declare function senderLabel(args: Record<string, unknown>, opts?: {
34
+ env?: NodeJS.ProcessEnv;
35
+ selfTitle?: string | null;
36
+ }): string;
37
+ /** Stamp `from` so deliverTo and the op result use the same resolved label. */
38
+ export declare function withSenderLabel(args: Record<string, unknown>, opts?: {
39
+ env?: NodeJS.ProcessEnv;
40
+ selfTitle?: string | null;
41
+ }): Record<string, unknown>;
42
+ /**
43
+ * Before forwarding over `ZSWARM_SERVE`, 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
+ */
47
+ export declare function attachKnownSender(args: Record<string, unknown>, env: NodeJS.ProcessEnv): Record<string, unknown>;
18
48
  export declare function bodyText(client: ZellijClient, args: Record<string, unknown>, body: string): string;
19
49
  /**
20
50
  * Caller `submit=` always wins. With none given, the pane's harness picks
@@ -2,8 +2,59 @@ import { resolveHarness } from "../harness.js";
2
2
  import { isTrue, numberArg } from "./util.js";
3
3
  /** Cursor / Claude leave this in scrollback after a successful submit. */
4
4
  const PASTE_MARKER = "[Pasted text";
5
- export function senderLabel(args) {
6
- return (typeof args.from === "string" && args.from.trim()) || "swarm";
5
+ const SENDER_LABEL_MAX = 48;
6
+ /** Drop characters that would break `[zswarm from=…]` or wrap the prefix. */
7
+ export function sanitizeSenderLabel(raw) {
8
+ const cleaned = raw.replace(/[\r\n[\]]+/g, " ").replace(/\s+/g, " ").trim();
9
+ if (!cleaned)
10
+ return "";
11
+ return cleaned.length <= SENDER_LABEL_MAX
12
+ ? cleaned
13
+ : cleaned.slice(0, SENDER_LABEL_MAX).trimEnd();
14
+ }
15
+ export function selfPaneTitle(client, panes) {
16
+ const id = client.selfPaneId;
17
+ if (!id)
18
+ return null;
19
+ const title = panes.find((p) => p.id === id)?.title;
20
+ return title?.trim() ? title : null;
21
+ }
22
+ /**
23
+ * Label for `[zswarm from=…]`. Explicit `from` wins, then `ZSWARM_FROM`,
24
+ * then the sending pane's title when we know that pane, else `swarm`.
25
+ */
26
+ export function resolveSender(args, opts = {}) {
27
+ if (typeof args.from === "string") {
28
+ const explicit = sanitizeSenderLabel(args.from);
29
+ if (explicit)
30
+ return { label: explicit, source: "arg" };
31
+ }
32
+ const envFrom = sanitizeSenderLabel(opts.env?.ZSWARM_FROM ?? "");
33
+ if (envFrom)
34
+ return { label: envFrom, source: "env" };
35
+ const title = sanitizeSenderLabel(opts.selfTitle ?? "");
36
+ if (title)
37
+ return { label: title, source: "title" };
38
+ return { label: "swarm", source: "default" };
39
+ }
40
+ export function senderLabel(args, opts = {}) {
41
+ return resolveSender(args, opts).label;
42
+ }
43
+ /** Stamp `from` so deliverTo and the op result use the same resolved label. */
44
+ export function withSenderLabel(args, opts = {}) {
45
+ return { ...args, from: senderLabel(args, opts) };
46
+ }
47
+ /**
48
+ * Before forwarding over `ZSWARM_SERVE`, 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
+ */
52
+ export function attachKnownSender(args, env) {
53
+ const op = String(args.op ?? "");
54
+ if (op !== "send" && op !== "broadcast")
55
+ return args;
56
+ const sender = resolveSender(args, { env });
57
+ return { ...args, from: sender.label };
7
58
  }
8
59
  export function bodyText(client, args, body) {
9
60
  return isTrue(args.raw) ? body : client.formatPeerMessage(senderLabel(args), body);