@zswarm/core 0.1.4 → 0.1.6

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.
Files changed (45) hide show
  1. package/README.md +4 -1
  2. package/dist/exec.d.ts +14 -1
  3. package/dist/exec.js +107 -14
  4. package/dist/harness.js +12 -7
  5. package/dist/index.d.ts +11 -10
  6. package/dist/index.js +8 -8
  7. package/dist/ops/bus.d.ts +16 -10
  8. package/dist/ops/bus.js +164 -98
  9. package/dist/ops/delivery.d.ts +3 -3
  10. package/dist/ops/delivery.js +4 -7
  11. package/dist/ops/dispatch.d.ts +7 -1
  12. package/dist/ops/dispatch.js +208 -68
  13. package/dist/ops/guards.d.ts +2 -0
  14. package/dist/ops/guards.js +11 -1
  15. package/dist/ops/observation.d.ts +8 -0
  16. package/dist/ops/observation.js +26 -0
  17. package/dist/ops/routing.d.ts +13 -0
  18. package/dist/ops/routing.js +21 -0
  19. package/dist/ops/serve.d.ts +1 -0
  20. package/dist/ops/serve.js +7 -1
  21. package/dist/ops/spawn.d.ts +3 -3
  22. package/dist/ops/spawn.js +130 -64
  23. package/dist/ops/status.d.ts +30 -5
  24. package/dist/ops/status.js +231 -91
  25. package/dist/ops/types.d.ts +7 -1
  26. package/dist/ops/util.d.ts +4 -0
  27. package/dist/ops/util.js +7 -1
  28. package/dist/ops/wait.d.ts +8 -1
  29. package/dist/ops/wait.js +7 -3
  30. package/dist/schema.d.ts +2 -0
  31. package/dist/schema.js +50 -8
  32. package/dist/state.d.ts +7 -6
  33. package/dist/state.js +77 -17
  34. package/dist/zellij/args.d.ts +4 -0
  35. package/dist/zellij/args.js +1 -0
  36. package/dist/zellij/binary.d.ts +34 -0
  37. package/dist/zellij/binary.js +169 -8
  38. package/dist/zellij/bus.d.ts +11 -0
  39. package/dist/zellij/bus.js +22 -1
  40. package/dist/zellij/client.d.ts +23 -5
  41. package/dist/zellij/client.js +99 -33
  42. package/dist/zellij/panes.js +3 -0
  43. package/dist/zellij/session.d.ts +19 -3
  44. package/dist/zellij/session.js +39 -9
  45. package/package.json +3 -3
package/dist/ops/bus.js CHANGED
@@ -1,7 +1,9 @@
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";
4
- import { isTrue } from "./util.js";
3
+ import { loadPolicy } from "../policy.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";
5
7
  /**
6
8
  * Whether to ask the event-bus plugin, and what to do when it does not answer.
7
9
  *
@@ -9,10 +11,14 @@ import { isTrue } from "./util.js";
9
11
  * prompt, because a pipe to a plugin that was never approved costs a process
10
12
  * and a timeout on every call and then falls back anyway.
11
13
  */
12
- /** Config keys to try before giving up; each one is a fresh pipe destination. */
13
- const MAX_KEY_ATTEMPTS = 3;
14
14
  /** A just-launched instance answers before Zellij has pushed it anything. */
15
15
  const COLD_RETRY_MS = 200;
16
+ /**
17
+ * A marker written this recently with no live pane is an in-flight install,
18
+ * not a closed pane. Relaunching it is how concurrent agents used to stack
19
+ * copies.
20
+ */
21
+ const INSTALL_IN_FLIGHT_MS = 10_000;
16
22
  /** Set once a pipe has failed, so one dead plugin costs one timeout, not many. */
17
23
  let processDisabled = null;
18
24
  /** The key that actually answered, per session, for the life of this process. */
@@ -30,8 +36,8 @@ function negative(raw) {
30
36
  const v = (raw ?? "").trim().toLowerCase();
31
37
  return v === "0" || v === "false" || v === "no" || v === "off";
32
38
  }
33
- export function planBus(client, state, env = process.env) {
34
- const marker = state.readBus();
39
+ export function planBus(client, state, env = process.env, session = "") {
40
+ const marker = session ? state.readBus(session) : null;
35
41
  // A marker can outlive the wasm it names — an upgrade renames the artifact,
36
42
  // so a remembered path that no longer exists must not shadow the current one.
37
43
  const remembered = marker && existsSync(marker.plugin) ? marker : null;
@@ -65,8 +71,10 @@ export function planBus(client, state, env = process.env) {
65
71
  return { ...base, enabled: true, reason: "installed" };
66
72
  return { ...base, reason: 'not installed; run zswarm({op:"bus", install:true})' };
67
73
  }
68
- async function askOnce(client, session, url, configKey, payload) {
69
- const result = await client.pipePlugin({ session, url, configKey, payload });
74
+ async function askOnce(client, session, url, configKey, payload, timeoutMs) {
75
+ const result = await client.pipePlugin({
76
+ session, url, configKey, payload, timeoutMs,
77
+ });
70
78
  return parseBusReply(result.stdout);
71
79
  }
72
80
  /**
@@ -75,62 +83,80 @@ async function askOnce(client, session, url, configKey, payload) {
75
83
  * blind (ready:false, paneUpdates:0) until something moves. Renaming a pane
76
84
  * to the title it already has is an invisible change that forces the push.
77
85
  */
78
- async function nudgeManifest(client, session) {
79
- const panes = await client.listPanes(session);
86
+ async function nudgeManifest(client, session, env, remaining) {
87
+ if (loadPolicy(env).readOnly)
88
+ return;
89
+ const listBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
90
+ if (listBudget <= 0)
91
+ return;
92
+ const panes = await client.listPanes(session, listBudget);
80
93
  const pane = panes.find((p) => !p.isPlugin && !p.exited && p.title.trim());
81
94
  if (!pane)
82
95
  return;
83
- await client.renamePane({ session, paneId: pane.id, name: pane.title });
96
+ const renameBudget = Math.min(DEFAULT_TIMEOUT_MS, remaining());
97
+ if (renameBudget <= 0)
98
+ return;
99
+ await client.renamePane({
100
+ session,
101
+ paneId: pane.id,
102
+ name: pane.title,
103
+ timeoutMs: renameBudget,
104
+ });
84
105
  }
85
106
  /**
86
107
  * One pipe, one answer — or null, and the caller polls instead.
87
108
  *
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.
109
+ * A freshly launched instance replies before Zellij has pushed it a manifest,
110
+ * so a not-ready answer is retried once rather than reported as an empty
111
+ * session. If it is still cold after that, one same-title rename nudges Zellij
112
+ * to push. A silent pipe used to rotate `instance=` keys, and `zellij pipe
113
+ * --plugin` loads a new copy for each key — that is how a dead bus became
114
+ * dozens of WASM panes. One remembered key, then polling.
93
115
  */
94
- export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status") {
95
- const plan = planBus(client, state, env);
116
+ export async function busSnapshot(client, state, session, clock, env = process.env, payload = "status", opts = {}) {
117
+ const remaining = () => {
118
+ throwIfAborted(opts.signal);
119
+ return opts.deadlineAt === undefined
120
+ ? Infinity
121
+ : Math.max(0, opts.deadlineAt - clock.now());
122
+ };
123
+ if (remaining() <= 0)
124
+ return null;
125
+ const plan = planBus(client, state, env, session);
96
126
  const pluginPath = plan.plugin;
97
- if (!plan.enabled || !plan.url || !pluginPath)
127
+ const url = plan.url;
128
+ if (!plan.enabled || !url || !pluginPath)
98
129
  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);
105
- snapshot =
106
- (await askOnce(client, session, plan.url, configKey, payload)) ??
107
- snapshot;
108
- }
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
- }
130
+ const configKey = answeredWith.get(session) ?? plan.configKey;
131
+ const ask = async () => {
132
+ const budget = Math.min(DEFAULT_BUS_TIMEOUT_MS, remaining());
133
+ if (budget <= 0)
134
+ return null;
135
+ const snapshot = await askOnce(client, session, url, configKey, payload, budget);
136
+ throwIfAborted(opts.signal);
137
+ return snapshot;
138
+ };
139
+ let snapshot = await ask();
140
+ if (snapshot && !snapshot.ready && remaining() > 0) {
141
+ await clock.sleep(Math.min(COLD_RETRY_MS, remaining()));
142
+ snapshot = (await ask()) ?? snapshot;
143
+ }
144
+ if (snapshot && !snapshot.ready && remaining() > 0) {
145
+ try {
146
+ await nudgeManifest(client, session, env, remaining);
147
+ snapshot = (await ask()) ?? snapshot;
120
148
  }
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 };
149
+ catch {
150
+ throwIfAborted(opts.signal);
151
+ // A failed nudge must not fail the op — the caller polls instead.
132
152
  }
133
- configKey = nextConfigKey(configKey);
153
+ }
154
+ // An expired request says nothing about bus health or an empty cold manifest.
155
+ if (remaining() <= 0 && !snapshot?.ready)
156
+ return null;
157
+ if (snapshot) {
158
+ answeredWith.set(session, configKey);
159
+ return { snapshot, configKey };
134
160
  }
135
161
  processDisabled = "plugin did not answer; using zellij polling for this run";
136
162
  return null;
@@ -146,10 +172,10 @@ export async function busSnapshot(client, state, session, clock, env = process.e
146
172
  * `dump-screen` returns them ragged. `normalizeScreen` erases exactly that
147
173
  * difference, and every caller here already normalizes before comparing.
148
174
  */
149
- export async function busScreens(client, state, session, paneIds, clock, env = process.env) {
175
+ export async function busScreens(client, state, session, paneIds, clock, env = process.env, timeoutMs) {
150
176
  if (paneIds.length < 2)
151
177
  return null;
152
- const plan = planBus(client, state, env);
178
+ const plan = planBus(client, state, env, session);
153
179
  if (!plan.enabled || !plan.url)
154
180
  return null;
155
181
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -158,6 +184,7 @@ export async function busScreens(client, state, session, paneIds, clock, env = p
158
184
  url: plan.url,
159
185
  configKey,
160
186
  panes: paneIds,
187
+ timeoutMs,
161
188
  });
162
189
  const parsed = parseScrollbackReply(reply.stdout);
163
190
  if (!parsed || !parsed.ready)
@@ -179,7 +206,7 @@ export async function busScreens(client, state, session, paneIds, clock, env = p
179
206
  * instance, no reply — and the caller runs its own loop.
180
207
  */
181
208
  export async function busWait(client, state, session, request, env = process.env) {
182
- const plan = planBus(client, state, env);
209
+ const plan = planBus(client, state, env, session);
183
210
  if (!plan.enabled || !plan.url)
184
211
  return null;
185
212
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -199,10 +226,10 @@ export async function busWait(client, state, session, request, env = process.env
199
226
  * `status` answers by sampling twice, without the gap between samples — but
200
227
  * relative to the previous call rather than a fixed window.
201
228
  */
202
- export async function busChanged(client, state, session, paneIds, env = process.env) {
229
+ export async function busChanged(client, state, session, paneIds, env = process.env, timeoutMs) {
203
230
  if (paneIds.length === 0)
204
231
  return null;
205
- const plan = planBus(client, state, env);
232
+ const plan = planBus(client, state, env, session);
206
233
  if (!plan.enabled || !plan.url)
207
234
  return null;
208
235
  const configKey = answeredWith.get(session) ?? plan.configKey;
@@ -211,20 +238,70 @@ export async function busChanged(client, state, session, paneIds, env = process.
211
238
  url: plan.url,
212
239
  configKey,
213
240
  panes: paneIds,
241
+ timeoutMs,
214
242
  });
215
243
  const parsed = parseChangedReply(reply.stdout);
216
244
  if (!parsed || !parsed.ready)
217
245
  return null;
218
246
  return parsed.panes.length === paneIds.length ? parsed : null;
219
247
  }
248
+ function installedReply(session, plugin, configKey, ready, panes, extra = {}) {
249
+ return {
250
+ ok: true,
251
+ data: {
252
+ session,
253
+ installed: true,
254
+ plugin,
255
+ url: busPluginUrl(plugin),
256
+ configKey,
257
+ ready,
258
+ panes,
259
+ ...extra,
260
+ },
261
+ };
262
+ }
263
+ async function listBusPluginPanes(client, session, pluginPath) {
264
+ const panes = await client.listPanes(session);
265
+ return panes.filter((pane) => isBusPluginPane(pane, pluginPath));
266
+ }
267
+ /** Close every zswarm-bus plugin pane in this session. Best-effort per pane. */
268
+ export async function closeBusPluginPanes(client, session, pluginPath) {
269
+ const closed = [];
270
+ const panes = await listBusPluginPanes(client, session, pluginPath);
271
+ for (const pane of panes) {
272
+ try {
273
+ await client.closePane({ session, paneId: pane.id });
274
+ closed.push(pane.id);
275
+ }
276
+ catch {
277
+ // A pane that vanished between list and close is the desired end state.
278
+ }
279
+ }
280
+ return closed;
281
+ }
220
282
  /** Report on the bus, install it, or forget it. */
221
283
  export async function busOp(client, state, args, clock, env = process.env, policy) {
284
+ const sessionArg = typeof args.session === "string" ? args.session : undefined;
222
285
  if (isTrue(args.clear)) {
223
- state.clearBus();
286
+ let session = null;
287
+ let closed = [];
288
+ try {
289
+ session = (await client.resolveSession(sessionArg)).session;
290
+ }
291
+ catch {
292
+ session = sessionArg?.trim() || null;
293
+ }
294
+ if (session) {
295
+ closed = await closeBusPluginPanes(client, session, resolveBusPlugin(env) ?? state.readBus(session)?.plugin ?? null);
296
+ state.clearBus(session);
297
+ }
298
+ else {
299
+ state.clearBus();
300
+ }
224
301
  resetBusCache();
225
- return { ok: true, data: { installed: false, cleared: true } };
302
+ return { ok: true, data: { installed: false, cleared: true, closed } };
226
303
  }
227
- const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
304
+ const { session } = await client.resolveSession(sessionArg);
228
305
  if (isTrue(args.install)) {
229
306
  // Installing opens a pane, which a read-only crew is not allowed to do.
230
307
  if (policy?.readOnly) {
@@ -234,32 +311,31 @@ export async function busOp(client, state, args, clock, env = process.env, polic
234
311
  if (!plugin) {
235
312
  throw new ZellijError("bus_missing", "no plugin wasm found; run pnpm build:plugin or set ZSWARM_BUS_PLUGIN");
236
313
  }
237
- const previous = state.readBus();
314
+ const previous = state.readBus(session);
238
315
  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
316
  if (previous && !force) {
242
317
  const live = await busSnapshot(client, state, session, clock, env);
243
318
  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
- };
319
+ return installedReply(session, previous.plugin, live.configKey, true, live.snapshot.panes.length, { note: "already installed" });
320
+ }
321
+ const existing = await listBusPluginPanes(client, session, plugin);
322
+ if (existing.length > 0) {
323
+ return installedReply(session, previous.plugin, previous.configKey, false, null, {
324
+ pane: existing[0]?.id ?? null,
325
+ note: "already installed but not answering; approve the permission prompt in the existing pane — do not --force unless you intend to replace it",
326
+ });
327
+ }
328
+ if (clock.now() - previous.installedAt < INSTALL_IN_FLIGHT_MS) {
329
+ return installedReply(session, previous.plugin, previous.configKey, false, null, {
330
+ note: "install already in flight; wait for the permission prompt rather than launching another pane",
331
+ });
257
332
  }
258
333
  }
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);
334
+ // Marker first so a concurrent install sees an in-flight write and
335
+ // stops. Then reap orphans so a reload cannot stack on the old copies.
336
+ const configKey = previous?.configKey ?? DEFAULT_BUS_KEY;
337
+ state.writeBus(session, { plugin, configKey, installedAt: clock.now() });
338
+ const closed = await closeBusPluginPanes(client, session, plugin);
263
339
  const url = busPluginUrl(plugin);
264
340
  const launched = await client.launchPlugin({
265
341
  session,
@@ -268,27 +344,17 @@ export async function busOp(client, state, args, clock, env = process.env, polic
268
344
  floating: true,
269
345
  skipCache: force,
270
346
  });
271
- state.writeBus({ plugin, configKey, installedAt: clock.now() });
272
347
  resetBusCache();
273
348
  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
- };
349
+ return installedReply(session, plugin, configKey, probe?.snapshot.ready ?? false, probe?.snapshot.panes.length ?? null, {
350
+ pane: launched.paneId,
351
+ closed,
352
+ note: probe
353
+ ? "bus answering; keep this floating pane open — closing it unloads the bus"
354
+ : "approve the plugin's permission prompt in the new pane, then re-run without --install",
355
+ });
290
356
  }
291
- const plan = planBus(client, state, env);
357
+ const plan = planBus(client, state, env, session);
292
358
  const probe = plan.enabled
293
359
  ? await busSnapshot(client, state, session, clock, env)
294
360
  : 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);
@@ -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>;