@zswarm/core 0.1.5 → 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/ops/bus.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { ZellijError } from "../errors.js";
3
3
  import { loadPolicy } from "../policy.js";
4
- import { DEFAULT_BUS_KEY, busPluginUrl, isBusPluginPane, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, resolveBusPlugin, scrollbackToScreen, } from "../zellij/bus.js";
5
- import { isTrue } from "./util.js";
4
+ import { DEFAULT_BUS_KEY, DEFAULT_BUS_TIMEOUT_MS, busPluginUrl, isBusPluginPane, parseBusReply, parseChangedReply, parseScrollbackReply, parseWaitReply, resolveBusPlugin, scrollbackToScreen, } from "../zellij/bus.js";
5
+ import { DEFAULT_TIMEOUT_MS } from "../zellij/binary.js";
6
+ import { isTrue, throwIfAborted } from "./util.js";
6
7
  /**
7
8
  * Whether to ask the event-bus plugin, and what to do when it does not answer.
8
9
  *
@@ -18,15 +19,18 @@ const COLD_RETRY_MS = 200;
18
19
  * copies.
19
20
  */
20
21
  const INSTALL_IN_FLIGHT_MS = 10_000;
21
- /** Set once a pipe has failed, so one dead plugin costs one timeout, not many. */
22
- let processDisabled = null;
22
+ /** Back off failed pipes briefly, isolated to their transport/session/plugin. */
23
+ const busFailures = new Map();
23
24
  /** The key that actually answered, per session, for the life of this process. */
24
25
  const answeredWith = new Map();
25
26
  /** Tests share a module instance; this drops what a previous case cached. */
26
27
  export function resetBusCache() {
27
- processDisabled = null;
28
+ busFailures.clear();
28
29
  answeredWith.clear();
29
30
  }
31
+ function busScope(client, session, plan) {
32
+ return JSON.stringify([client.contextKey(), session, plan.url, plan.configKey]);
33
+ }
30
34
  function affirmative(raw) {
31
35
  const v = (raw ?? "").trim().toLowerCase();
32
36
  return v === "1" || v === "true" || v === "yes" || v === "on";
@@ -61,8 +65,12 @@ export function planBus(client, state, env = process.env, session = "") {
61
65
  reason: "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN",
62
66
  };
63
67
  }
64
- if (processDisabled)
65
- 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);
66
74
  if (affirmative(env.ZSWARM_BUS)) {
67
75
  return { ...base, enabled: true, reason: "ZSWARM_BUS=1" };
68
76
  }
@@ -70,8 +78,10 @@ export function planBus(client, state, env = process.env, session = "") {
70
78
  return { ...base, enabled: true, reason: "installed" };
71
79
  return { ...base, reason: 'not installed; run zswarm({op:"bus", install:true})' };
72
80
  }
73
- async function askOnce(client, session, url, configKey, payload) {
74
- const result = await client.pipePlugin({ session, url, configKey, payload });
81
+ async function askOnce(client, session, url, configKey, payload, timeoutMs) {
82
+ const result = await client.pipePlugin({
83
+ session, url, configKey, payload, timeoutMs,
84
+ });
75
85
  return parseBusReply(result.stdout);
76
86
  }
77
87
  /**
@@ -80,14 +90,25 @@ async function askOnce(client, session, url, configKey, payload) {
80
90
  * blind (ready:false, paneUpdates:0) until something moves. Renaming a pane
81
91
  * to the title it already has is an invisible change that forces the push.
82
92
  */
83
- async function nudgeManifest(client, session, env) {
93
+ async function nudgeManifest(client, session, env, remaining) {
84
94
  if (loadPolicy(env).readOnly)
85
95
  return;
86
- const panes = await client.listPanes(session);
96
+ const listBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
97
+ if (listBudget <= 0)
98
+ return;
99
+ const panes = await client.listPanes(session, listBudget, { fresh: true });
87
100
  const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
88
101
  if (!pane)
89
102
  return;
90
- await client.renamePane({ session, paneId: pane.id, name: pane.title });
103
+ const renameBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
104
+ if (renameBudget <= 0)
105
+ return;
106
+ await client.renamePane({
107
+ session,
108
+ paneId: pane.id,
109
+ name: pane.title,
110
+ timeoutMs: renameBudget,
111
+ });
91
112
  }
92
113
  /**
93
114
  * One pipe, one answer — or null, and the caller polls instead.
@@ -99,35 +120,61 @@ async function nudgeManifest(client, session, env) {
99
120
  * --plugin` loads a new copy for each key — that is how a dead bus became
100
121
  * dozens of WASM panes. One remembered key, then polling.
101
122
  */
102
- export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status") {
123
+ export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status", opts = {}) {
124
+ const remaining = () => {
125
+ throwIfAborted(opts.signal);
126
+ return opts.deadlineAt === undefined
127
+ ? Infinity
128
+ : Math.max(0, opts.deadlineAt - clock.now());
129
+ };
130
+ if (remaining() <= 0)
131
+ return null;
103
132
  const plan = planBus(client, state, env, session);
104
133
  const pluginPath = plan.plugin;
105
- if (!plan.enabled || !plan.url || !pluginPath)
134
+ const url = plan.url;
135
+ if (!plan.enabled || !url || !pluginPath)
106
136
  return null;
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;
137
+ const scope = busScope(client, session, plan);
138
+ const configKey = answeredWith.get(scope) ?? plan.configKey;
139
+ const ask = async () => {
140
+ const budget = Math.min(DEFAULT_BUS_TIMEOUT_MS, remaining());
141
+ if (budget <= 0)
142
+ return null;
143
+ const snapshot = await askOnce(client, session, url, configKey, payload, budget);
144
+ throwIfAborted(opts.signal);
145
+ return snapshot;
146
+ };
147
+ let snapshot = await ask();
148
+ if (snapshot && !snapshot.ready && remaining() > 0) {
149
+ await clock.sleep(Math.min(COLD_RETRY_MS, remaining()));
150
+ snapshot = (await ask()) ?? snapshot;
114
151
  }
115
- if (snapshot && !snapshot.ready) {
152
+ if (snapshot && !snapshot.ready && remaining() > 0) {
116
153
  try {
117
- await nudgeManifest(client, session, env);
118
- snapshot =
119
- (await askOnce(client, session, plan.url, configKey, payload)) ??
120
- snapshot;
154
+ await nudgeManifest(client, session, env, remaining);
155
+ snapshot = (await ask()) ?? snapshot;
121
156
  }
122
157
  catch {
158
+ throwIfAborted(opts.signal);
123
159
  // A failed nudge must not fail the op — the caller polls instead.
124
160
  }
125
161
  }
162
+ // An expired request says nothing about bus health or an empty cold manifest.
163
+ if (remaining() <= 0 && !snapshot?.ready)
164
+ return null;
126
165
  if (snapshot) {
127
- 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
+ ]));
128
173
  return { snapshot, configKey };
129
174
  }
130
- 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);
131
178
  return null;
132
179
  }
133
180
  /**
@@ -141,18 +188,19 @@ export async function busSnapshot(client, state, session, clock, env = process.e
141
188
  * `dump-screen` returns them ragged. `normalizeScreen` erases exactly that
142
189
  * difference, and every caller here already normalizes before comparing.
143
190
  */
144
- export async function busScreens(client, state, session, paneIds, clock, env = process.env) {
191
+ export async function busScreens(client, state, session, paneIds, clock, env = process.env, timeoutMs) {
145
192
  if (paneIds.length < 2)
146
193
  return null;
147
194
  const plan = planBus(client, state, env, session);
148
195
  if (!plan.enabled || !plan.url)
149
196
  return null;
150
- const configKey = answeredWith.get(session) ?? plan.configKey;
197
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
151
198
  const reply = await client.scrollbackPlugin({
152
199
  session,
153
200
  url: plan.url,
154
201
  configKey,
155
202
  panes: paneIds,
203
+ timeoutMs,
156
204
  });
157
205
  const parsed = parseScrollbackReply(reply.stdout);
158
206
  if (!parsed || !parsed.ready)
@@ -177,7 +225,7 @@ export async function busWait(client, state, session, request, env = process.env
177
225
  const plan = planBus(client, state, env, session);
178
226
  if (!plan.enabled || !plan.url)
179
227
  return null;
180
- const configKey = answeredWith.get(session) ?? plan.configKey;
228
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
181
229
  const reply = await client.waitPlugin({
182
230
  session,
183
231
  url: plan.url,
@@ -194,23 +242,25 @@ export async function busWait(client, state, session, request, env = process.env
194
242
  * `status` answers by sampling twice, without the gap between samples — but
195
243
  * relative to the previous call rather than a fixed window.
196
244
  */
197
- export async function busChanged(client, state, session, paneIds, env = process.env) {
245
+ export async function busChanged(client, state, session, paneIds, env = process.env, timeoutMs) {
198
246
  if (paneIds.length === 0)
199
247
  return null;
200
248
  const plan = planBus(client, state, env, session);
201
249
  if (!plan.enabled || !plan.url)
202
250
  return null;
203
- const configKey = answeredWith.get(session) ?? plan.configKey;
251
+ const configKey = answeredWith.get(busScope(client, session, plan)) ?? plan.configKey;
204
252
  const reply = await client.changedPlugin({
205
253
  session,
206
254
  url: plan.url,
207
255
  configKey,
208
256
  panes: paneIds,
257
+ timeoutMs,
209
258
  });
210
259
  const parsed = parseChangedReply(reply.stdout);
211
260
  if (!parsed || !parsed.ready)
212
261
  return null;
213
- 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;
214
264
  }
215
265
  function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
216
266
  return {
@@ -228,7 +278,7 @@ function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
228
278
  };
229
279
  }
230
280
  async function listBusPluginPanes(client, session, pluginPath) {
231
- const panes = await client.listPanes(session);
281
+ const panes = await client.listPanes(session, undefined, { fresh: true });
232
282
  return panes.filter((pane) => isBusPluginPane(pane, pluginPath));
233
283
  }
234
284
  /** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
@@ -1,4 +1,10 @@
1
1
  import { type ZellijClient } from "../zellij/client.js";
2
2
  import type { DispatchDeps, OpsResult } from "./types.js";
3
- /** Shared MCP/CLI dispatch for zswarm ops. */
3
+ /**
4
+ * Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
5
+ * and serve (and remote IPC). `--ssh` sets the destination for this call and
6
+ * clears serve so the flag wins over a sticky ZSWARM_SERVE.
7
+ */
8
+ export declare function resolveInvocationEnv(args: Record<string, unknown>, base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
9
+ /** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
4
10
  export declare function dispatchZswarm(args: Record<string, unknown>, injected?: ZellijClient, deps?: DispatchDeps): Promise<OpsResult>;