@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.
@@ -6,21 +6,60 @@ import { assertOpAllowed, assertPaneAllowed, loadPolicy, } from "../policy.js";
6
6
  import { createStateStore } from "../state.js";
7
7
  import { busToPanes } from "../zellij/bus.js";
8
8
  import { broadcast } from "./broadcast.js";
9
- import { busChanged, busOp, busScreens, busSnapshot, busWait } from "./bus.js";
9
+ import { busChanged, busOp, busScreens, busSnapshot, busWait, planBus } from "./bus.js";
10
10
  import { dumpLayoutOp, focusTarget, listTabsOp, renameTarget, stackTargets, } from "./panes.js";
11
11
  import { peerCheckpoint, peerDiff } from "./review.js";
12
12
  import { attachKnownSender, deliverTo, withSenderLabel, selfPaneTitle } from "./delivery.js";
13
- import { assertNotPlugin, assertNotSelf, assertPaneExpects, assertWritable, } from "./guards.js";
13
+ import { assertNotPlugin, assertNotSelf, assertExpectedScreen, assertWritable, } from "./guards.js";
14
14
  import { readDeliveryLog } from "./log.js";
15
15
  import { awaitSignal, listSignals, postSignal } from "./signals.js";
16
16
  import { spawnPane } from "./spawn.js";
17
- import { peerStatus } from "./status.js";
17
+ import { observationBudget } from "./observation.js";
18
+ import { invocationContext } from "./routing.js";
19
+ import { DEFAULT_STATUS_TIMEOUT_MS, peerStatus, } from "./status.js";
18
20
  import { tailPane } from "./tail.js";
19
21
  import { OP_NAMES } from "../schema.js";
20
- import { dumpMaxChars, fail, isTrue, isVerbose, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
22
+ import { dumpMaxChars, fail, isTrue, isVerbose, numberArg, optionalString, paneViewBus, paneViewFull, paneViewSlim, throwIfAborted, truncateDumpText, } from "./util.js";
21
23
  import { waitForPane } from "./wait.js";
22
24
  import { listPeerWorktrees, removePeerWorktree } from "./worktree.js";
23
25
  import { callServe, installServeLogon, serveCallTimeout, uninstallServeLogon, } from "./serve.js";
26
+ /**
27
+ * Per-invocation routing from `--local` / `--ssh`. `--local` clears both SSH
28
+ * and serve (and remote IPC). `--ssh` sets the destination for this call and
29
+ * clears serve so the flag wins over a sticky ZSWARM_SERVE.
30
+ */
31
+ export function resolveInvocationEnv(args, base) {
32
+ const local = isTrue(args.local);
33
+ const sshDest = typeof args.ssh === "string" && args.ssh.trim() ? args.ssh.trim() : null;
34
+ const serveAddress = optionalString(args.serveAddress);
35
+ if (Number(local) + Number(Boolean(sshDest)) + Number(Boolean(serveAddress)) > 1) {
36
+ throw new ZellijError("usage", "choose only one of --local, --ssh, or --serve");
37
+ }
38
+ if (!local && !sshDest && !serveAddress)
39
+ return base;
40
+ const env = { ...base };
41
+ if (serveAddress) {
42
+ env.ZSWARM_SERVE = serveAddress;
43
+ delete env.ZSWARM_SSH;
44
+ return env;
45
+ }
46
+ if (local) {
47
+ delete env.ZSWARM_SSH;
48
+ delete env.ZSWARM_SSH_OPTS;
49
+ delete env.ZSWARM_SSH_MODE;
50
+ delete env.ZSWARM_SSH_BIN;
51
+ delete env.ZSWARM_TMP;
52
+ delete env.ZSWARM_REMOTE_BIN;
53
+ delete env.ZSWARM_REMOTE_SHELL;
54
+ delete env.ZSWARM_SERVE;
55
+ delete env.ZSWARM_SERVE_TOKEN;
56
+ return env;
57
+ }
58
+ env.ZSWARM_SSH = sshDest;
59
+ delete env.ZSWARM_SERVE;
60
+ delete env.ZSWARM_SERVE_TOKEN;
61
+ return env;
62
+ }
24
63
  /** Same default `wait` applies: match when a needle is given, else idle. */
25
64
  function waitMode(args) {
26
65
  const requested = typeof args.for === "string" ? args.for.trim() : "";
@@ -53,40 +92,54 @@ function assertSshGitAllowed(env, op, args) {
53
92
  : "uses local git";
54
93
  throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
55
94
  }
56
- async function resolveTarget(client, args, state, clock, env, policy, op) {
95
+ async function resolveTarget(client, args, state, clock, env, policy, op, signal) {
57
96
  const to = String(args.to ?? "").trim();
58
97
  if (!to)
59
98
  throw new ZellijError("missing_peer", "to required");
60
- const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
61
- // The plugin manifest carries no cwd/command/floating, so verbose
62
- // responses have to go the polling route.
63
- const bus = isVerbose(args)
64
- ? null
65
- : await busSnapshot(client, state, session, clock, env);
99
+ const budget = observationBudget(clock, numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 }), signal);
100
+ const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
101
+ const { session } = await client.resolveSession(optionalString(args.session), budget.require());
102
+ const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
103
+ deadlineAt: budget.deadline, signal,
104
+ });
66
105
  let panes = null;
67
106
  if (bus) {
68
- const busPanes = busToPanes(bus.snapshot);
107
+ const snapshot = busToPanes(bus.snapshot);
69
108
  try {
70
- client.resolvePane(busPanes, to);
71
- panes = busPanes;
109
+ client.resolvePane(snapshot, to);
110
+ panes = snapshot;
72
111
  }
73
- catch {
74
- // A command-shaped `to` only matches the polled list.
112
+ catch { /* An absent title/command/ID gets a fresh direct observation. */ }
113
+ }
114
+ const until = Math.min(budget.deadline, clock.now() + observeMs);
115
+ let pane;
116
+ let fresh = isTrue(args.fresh) || Boolean(op);
117
+ for (;;) {
118
+ const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
119
+ panes ??= await client.listPanes(session, left, { fresh });
120
+ throwIfAborted(signal);
121
+ try {
122
+ pane = client.resolvePane(panes, to);
123
+ break;
124
+ }
125
+ catch (err) {
126
+ if (!(err instanceof ZellijError) || err.code !== "peer_not_found" || clock.now() >= until)
127
+ throw err;
128
+ await budget.pause(until);
129
+ if (clock.now() >= until)
130
+ throw err;
131
+ panes = null;
132
+ fresh = true;
75
133
  }
76
134
  }
77
- if (!panes)
78
- panes = await client.listPanes(session);
79
- const pane = client.resolvePane(panes, to);
80
135
  if (policy && op)
81
136
  assertPaneAllowed(policy, pane, op);
82
137
  return { session, panes, pane };
83
138
  }
84
- /** Shared MCP/CLI dispatch for zswarm ops. */
85
- export async function dispatchZswarm(args, injected, deps = {}) {
139
+ async function dispatchOperation(args, injected, deps, context, env) {
86
140
  const op = String(args.op ?? "");
87
141
  const verbose = isVerbose(args);
88
142
  const signal = deps.signal;
89
- throwIfAborted(signal);
90
143
  const clock = {
91
144
  now: deps.now ?? (() => Date.now()),
92
145
  sleep: deps.sleep ??
@@ -95,9 +148,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
95
148
  reject(new ZellijError("cancelled", "operation cancelled"));
96
149
  return;
97
150
  }
98
- const timer = setTimeout(resolve, ms);
151
+ const timer = setTimeout(() => {
152
+ signal?.removeEventListener("abort", onAbort);
153
+ resolve();
154
+ }, ms);
99
155
  const onAbort = () => {
100
156
  clearTimeout(timer);
157
+ signal?.removeEventListener("abort", onAbort);
101
158
  reject(new ZellijError("cancelled", "operation cancelled"));
102
159
  };
103
160
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -107,10 +164,10 @@ export async function dispatchZswarm(args, injected, deps = {}) {
107
164
  let gitClient = deps.git ?? null;
108
165
  const git = () => (gitClient ??= createGitClient());
109
166
  let stateStore = deps.state ?? null;
110
- const state = () => (stateStore ??= createStateStore());
111
- const policy = deps.policy ?? loadPolicy(deps.env);
112
- const env = deps.env ?? process.env;
167
+ const state = () => (stateStore ??= createStateStore({ env }));
113
168
  try {
169
+ throwIfAborted(signal);
170
+ const policy = deps.policy ?? loadPolicy(env);
114
171
  // Policy gates the op before anything touches the session.
115
172
  assertOpAllowed(policy, op);
116
173
  assertSshGitAllowed(env, op, args);
@@ -132,21 +189,55 @@ export async function dispatchZswarm(args, injected, deps = {}) {
132
189
  // An injected client is a unit-test (or in-process) Zellij; do not skip it
133
190
  // just because the host env has ZSWARM_SERVE set.
134
191
  if (!injected && env.ZSWARM_SERVE?.trim()) {
135
- return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
192
+ const request = { ...attachKnownSender(args, env) };
193
+ delete request.serveAddress; // Routing is consumed here, never forwarded back into a tunnel.
194
+ return await callServe(env.ZSWARM_SERVE.trim(), request, serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
136
195
  }
137
- const client = injected ?? createZellijClient({ env: deps.env, signal });
196
+ const baseClient = injected ?? createZellijClient({ env, signal, cache: isTrue(args.fresh) ? false : undefined });
197
+ const client = {
198
+ ...baseClient,
199
+ get transport() { return baseClient.transport; },
200
+ async resolveSession(...input) {
201
+ const resolved = await baseClient.resolveSession(...input);
202
+ context.session = resolved.session;
203
+ context.origin.session = resolved.source;
204
+ return resolved;
205
+ },
206
+ };
138
207
  switch (op) {
139
208
  case "sessions": {
140
- const sessions = await client.listSessions();
141
- return { ok: true, data: { sessions, zellij: client.zellijPath } };
209
+ // Resolve SSH/IPC first so a Windows interactive crew is reachable
210
+ // before --live filters EXITED rows.
211
+ const listed = await client.listSessions();
212
+ const transport = client.transport;
213
+ const ipc = transport.ipc;
214
+ if (ipc &&
215
+ ipc.requested?.toLowerCase() === "auto" &&
216
+ (ipc.status === "failed" ||
217
+ ipc.status === "expired" ||
218
+ ipc.status === "cancelled")) {
219
+ throw new ZellijError("ipc_unreachable", `ZSWARM_TMP=auto could not discover the remote Zellij IPC directory (status=${ipc.status}). Set ZSWARM_TMP to the desktop TEMP, confirm ZSWARM_SSH_MODE=interactive with a logged-on user, or use zswarm serve. Refusing to treat EXITED rows as "no live crew".`);
220
+ }
221
+ const includeExited = isTrue(args.all);
222
+ const sessions = includeExited
223
+ ? listed
224
+ : listed.filter((s) => !s.exited);
225
+ const data = {
226
+ sessions,
227
+ zellij: client.zellijPath,
228
+ transport,
229
+ filter: includeExited ? "all" : "live",
230
+ };
231
+ return { ok: true, data };
142
232
  }
143
233
  case "list": {
144
234
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
145
235
  // The plugin's manifest carries no cwd or floating flag, so anything
146
236
  // verbose has to go the polling route.
147
- const bus = verbose
237
+ const observedBus = verbose
148
238
  ? null
149
- : await busSnapshot(client, state(), session, clock, deps.env);
239
+ : await busSnapshot(client, state(), session, clock, env);
240
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
150
241
  const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
151
242
  const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
152
243
  .filter((p) => !p.isPlugin)
@@ -165,16 +256,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
165
256
  const body = String(args.body ?? args.text ?? "");
166
257
  if (!body.trim())
167
258
  throw new ZellijError("missing_body", "body required");
168
- const { session, pane, panes } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
259
+ const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
169
260
  assertWritable(client, pane, args, "send");
170
- // A pane that dropped back to a shell will *run* the message. There is
171
- // no reliable way to tell an agent from a prompt, so the caller names
172
- // something the screen must show first.
173
- const expect = typeof args.expect === "string" ? args.expect.trim() : "";
174
- if (expect) {
175
- const screen = await client.dumpPane({ session, paneId: pane.id });
176
- assertPaneExpects(screen.text, expect, pane.id);
177
- }
261
+ await assertExpectedScreen(client, session, pane.id, args, signal);
178
262
  const labeled = withSenderLabel(args, {
179
263
  env,
180
264
  selfTitle: selfPaneTitle(client, panes),
@@ -205,8 +289,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
205
289
  return await broadcast(client, state(), args, clock, policy, env);
206
290
  case "keys":
207
291
  case "interrupt": {
208
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
292
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
209
293
  assertWritable(client, pane, args, op);
294
+ await assertExpectedScreen(client, session, pane.id, args, signal);
210
295
  const chars = typeof args.chars === "string" ? args.chars : "";
211
296
  if (op === "keys" && chars) {
212
297
  await client.writeChars({ session, paneId: pane.id, chars });
@@ -259,7 +344,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
259
344
  };
260
345
  }
261
346
  case "dump": {
262
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env);
347
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
263
348
  const dumped = await client.dumpPane({
264
349
  session,
265
350
  paneId: pane.id,
@@ -281,11 +366,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
281
366
  };
282
367
  }
283
368
  case "tail": {
284
- const target = await resolveTarget(client, args, state(), clock, deps.env);
369
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
285
370
  return await tailPane(client, state(), args, target);
286
371
  }
287
372
  case "wait": {
288
- const target = await resolveTarget(client, args, state(), clock, deps.env);
373
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
289
374
  // The plugin holds one pipe for the whole wait and polls far tighter
290
375
  // than spawning a process allows. It declines a regex needle — it has
291
376
  // no engine — and then this is the loop that runs.
@@ -297,24 +382,66 @@ export async function dispatchZswarm(args, injected, deps = {}) {
297
382
  match: typeof args.match === "string" ? args.match : null,
298
383
  ignoreCase: isTrue(args.ignoreCase),
299
384
  ...timing,
300
- }, deps.env);
385
+ }, env);
301
386
  return await waitForPane(client, target, args, clock, viaBus, deps.signal);
302
387
  }
303
388
  case "status": {
304
- const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
305
- // Verbose reports cwd and command, which the plugin manifest lacks.
306
- const bus = verbose
307
- ? null
308
- : await busSnapshot(client, state(), session, clock, deps.env);
309
- let supplied = bus
310
- ? {
311
- session,
312
- panes: busToPanes(bus.snapshot),
313
- source: "plugin",
314
- // Sampling is 2N dumps; the plugin reads them all in one pipe.
315
- readScreens: (paneIds) => busScreens(client, state(), session, paneIds, clock, deps.env),
316
- readChanged: async (paneIds) => {
317
- const reply = await busChanged(client, state(), session, paneIds, deps.env);
389
+ // One overall deadline covering session resolve, bus snapshot, IPC,
390
+ // and screen samples — not just the dump loop inside peerStatus.
391
+ const statusBudget = numberArg(args, "timeoutMs", DEFAULT_STATUS_TIMEOUT_MS, { min: 1_000, max: 900_000 });
392
+ const deadlineAt = clock.now() + statusBudget;
393
+ const remaining = () => Math.max(0, deadlineAt - clock.now());
394
+ const setupBudget = () => {
395
+ throwIfAborted(signal);
396
+ const left = remaining();
397
+ if (left <= 0) {
398
+ throw new ZellijError("zellij_failed", "status timed out during setup");
399
+ }
400
+ return left;
401
+ };
402
+ throwIfAborted(signal);
403
+ const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
404
+ throwIfAborted(signal);
405
+ // Once the session is known, verbose metadata and the bus manifest
406
+ // are independent reads. Both consume this operation's remaining time.
407
+ const readBus = () => remaining() <= 0 ? Promise.resolve(null)
408
+ : busSnapshot(client, state(), session, clock, env, "status", { deadlineAt, signal });
409
+ let observedBus;
410
+ let detailedPanes;
411
+ if (verbose) {
412
+ const reads = await Promise.allSettled([
413
+ readBus(), client.listPanes(session, setupBudget(), { fresh: true }),
414
+ ]);
415
+ throwIfAborted(signal);
416
+ if (reads[1].status === "rejected")
417
+ throw reads[1].reason;
418
+ if (reads[0].status === "rejected")
419
+ throw reads[0].reason;
420
+ observedBus = reads[0].value;
421
+ detailedPanes = reads[1].value;
422
+ }
423
+ else {
424
+ observedBus = await readBus();
425
+ }
426
+ const bus = observedBus?.snapshot.ready ? observedBus : null;
427
+ throwIfAborted(signal);
428
+ // Always hand peerStatus the resolved session (and panes) so it does
429
+ // not spend the overall budget resolving again.
430
+ const panes = detailedPanes ?? (bus
431
+ ? busToPanes(bus.snapshot)
432
+ : await client.listPanes(session, setupBudget()));
433
+ throwIfAborted(signal);
434
+ let supplied = {
435
+ session,
436
+ panes,
437
+ source: bus && !verbose ? "plugin" : "zellij",
438
+ };
439
+ if (bus) {
440
+ supplied = {
441
+ ...supplied,
442
+ readScreens: (paneIds, timeoutMs) => busScreens(client, state(), session, paneIds, clock, env, timeoutMs),
443
+ readChanged: async (paneIds, timeoutMs) => {
444
+ const reply = await busChanged(client, state(), session, paneIds, env, timeoutMs);
318
445
  if (!reply)
319
446
  return null;
320
447
  return new Map(reply.panes.map((p) => [
@@ -322,23 +449,37 @@ export async function dispatchZswarm(args, injected, deps = {}) {
322
449
  { changed: p.changed, first: p.first, screen: p.screen },
323
450
  ]));
324
451
  },
325
- }
326
- : null;
452
+ };
453
+ }
327
454
  const only = typeof args.to === "string" ? args.to.trim() : "";
328
- if (supplied && only) {
455
+ if (bus && only) {
329
456
  try {
330
457
  client.resolvePane(supplied.panes, only);
331
458
  }
332
459
  catch {
333
- // The manifest has no commands, so a command-shaped `to` only
334
- // resolves against the polled list.
335
- supplied = null;
460
+ // Command-shaped `to` needs the polled list; re-fetch without bus.
461
+ const polled = await client.listPanes(session, setupBudget());
462
+ supplied = { ...supplied, session, panes: polled, source: "zellij" };
336
463
  }
337
464
  }
338
- return await peerStatus(client, args, clock, supplied);
465
+ const result = await peerStatus(client, args, clock, supplied, {
466
+ deadlineAt,
467
+ signal,
468
+ });
469
+ if (result.ok && result.data && typeof result.data === "object") {
470
+ const data = result.data;
471
+ data.polling = {
472
+ busAvailable: Boolean(bus),
473
+ ...(bus ? {} : { reason: planBus(client, state(), env, session).reason }),
474
+ ...(client.remote ? {
475
+ recommendation: "For frequent status calls, run zswarm serve beside Zellij and use --serve 127.0.0.1:9419 through an SSH tunnel; direct SSH cannot use the local event bus.",
476
+ } : {}),
477
+ };
478
+ }
479
+ return result;
339
480
  }
340
481
  case "bus":
341
- return await busOp(client, state(), args, clock, deps.env, policy);
482
+ return await busOp(client, state(), args, clock, env, policy);
342
483
  case "signal":
343
484
  return postSignal(state(), args, clock);
344
485
  case "signals":
@@ -362,13 +503,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
362
503
  case "checkpoint":
363
504
  return await peerCheckpoint(git(), args, clock);
364
505
  case "spawn":
365
- return await spawnPane(client, args, deps.git);
506
+ return await spawnPane(client, args, deps.git, clock, signal);
366
507
  case "worktrees":
367
508
  return await listPeerWorktrees(git(), client, args);
368
509
  case "unworktree":
369
510
  return await removePeerWorktree(git(), client, args);
370
511
  case "close": {
371
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
512
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
372
513
  // Not assertWritable: closing an exited pane is the point of close.
373
514
  assertNotPlugin(pane, "close");
374
515
  assertNotSelf(client, pane, args, "close");
@@ -393,3 +534,27 @@ export async function dispatchZswarm(args, injected, deps = {}) {
393
534
  return fail(err);
394
535
  }
395
536
  }
537
+ /** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
538
+ export async function dispatchZswarm(args, injected, deps = {}) {
539
+ try {
540
+ const env = resolveInvocationEnv(args, deps.env ?? process.env);
541
+ const context = invocationContext(args, env, injected?.transport);
542
+ const result = await dispatchOperation(args, injected, deps, context, env);
543
+ if (context.transport === "serve" && result.context) {
544
+ context.server = result.context;
545
+ context.session = result.context.session;
546
+ context.origin.session = result.context.origin.session;
547
+ }
548
+ else if (context.transport === "serve" && result.ok && result.data && typeof result.data === "object") {
549
+ const session = result.data.session;
550
+ if (typeof session === "string") {
551
+ context.session = session;
552
+ context.origin.session = "server";
553
+ }
554
+ }
555
+ return { ...result, context };
556
+ }
557
+ catch (err) {
558
+ return fail(err);
559
+ }
560
+ }
@@ -13,3 +13,5 @@ export declare function assertWritable(client: ZellijClient, pane: ZellijPane, a
13
13
  * in a safety check is a foot-gun.
14
14
  */
15
15
  export declare function assertPaneExpects(screen: string, expect: string, paneId: string): void;
16
+ /** Check immediately before input. A failed read or mismatch must not write. */
17
+ export declare function assertExpectedScreen(client: ZellijClient, session: string, paneId: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { ZellijError } from "../errors.js";
2
- import { isTrue } from "./util.js";
2
+ import { isTrue, optionalString, throwIfAborted } from "./util.js";
3
3
  export function assertNotPlugin(pane, action) {
4
4
  if (pane.isPlugin) {
5
5
  throw new ZellijError("pane_is_plugin", `${pane.id} is a plugin pane; ${action} targets terminal panes`);
@@ -34,3 +34,13 @@ export function assertPaneExpects(screen, expect, paneId) {
34
34
  return;
35
35
  throw new ZellijError("expect_missing", `${paneId} screen does not contain ${JSON.stringify(expect)}`);
36
36
  }
37
+ /** Check immediately before input. A failed read or mismatch must not write. */
38
+ export async function assertExpectedScreen(client, session, paneId, args, signal) {
39
+ throwIfAborted(signal);
40
+ const expect = optionalString(args.expect);
41
+ if (!expect)
42
+ return;
43
+ const screen = await client.dumpPane({ session, paneId });
44
+ throwIfAborted(signal);
45
+ assertPaneExpects(screen.text, expect, paneId);
46
+ }
@@ -0,0 +1,8 @@
1
+ import type { Clock } from "./types.js";
2
+ /** One deadline shared by setup, child processes, retries, and sleeps. */
3
+ export declare function observationBudget(clock: Clock, timeoutMs: number, signal?: AbortSignal): {
4
+ deadline: number;
5
+ remaining: () => number;
6
+ require(): number;
7
+ pause(until?: number): Promise<void>;
8
+ };
@@ -0,0 +1,26 @@
1
+ import { ZellijError } from "../errors.js";
2
+ import { throwIfAborted } from "./util.js";
3
+ /** One deadline shared by setup, child processes, retries, and sleeps. */
4
+ export function observationBudget(clock, timeoutMs, signal) {
5
+ const deadline = clock.now() + timeoutMs;
6
+ const remaining = () => {
7
+ throwIfAborted(signal);
8
+ return Math.max(0, deadline - clock.now());
9
+ };
10
+ return {
11
+ deadline,
12
+ remaining,
13
+ require() {
14
+ const left = remaining();
15
+ if (left <= 0)
16
+ throw new ZellijError("observation_timeout", "observation deadline expired");
17
+ return left;
18
+ },
19
+ async pause(until = deadline) {
20
+ const ms = Math.min(100, remaining(), Math.max(0, until - clock.now()));
21
+ if (ms > 0)
22
+ await clock.sleep(ms);
23
+ throwIfAborted(signal);
24
+ },
25
+ };
26
+ }
package/dist/ops/panes.js CHANGED
@@ -16,7 +16,7 @@ export async function renameTarget(client, args, policy) {
16
16
  const sess = await session(client, args);
17
17
  const tabKey = optionalString(args.tab);
18
18
  if (tabKey) {
19
- const tab = client.resolveTab(await client.listTabs(sess), tabKey);
19
+ const tab = client.resolveTab(await client.listTabs(sess, undefined, { fresh: true }), tabKey);
20
20
  const renamed = await client.renameTab({
21
21
  session: sess,
22
22
  tabId: tab.id,
@@ -30,7 +30,7 @@ export async function renameTarget(client, args, policy) {
30
30
  const to = optionalString(args.to);
31
31
  if (!to)
32
32
  throw new ZellijError("missing_peer", "to (or tab) required");
33
- const pane = client.resolvePane(await client.listPanes(sess), to);
33
+ const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
34
34
  if (policy)
35
35
  assertPaneAllowed(policy, pane, "rename");
36
36
  await client.renamePane({ session: sess, paneId: pane.id, name });
@@ -44,7 +44,7 @@ export async function focusTarget(client, args, policy) {
44
44
  if (!to)
45
45
  throw new ZellijError("missing_peer", "to required");
46
46
  const sess = await session(client, args);
47
- const pane = client.resolvePane(await client.listPanes(sess), to);
47
+ const pane = client.resolvePane(await client.listPanes(sess, undefined, { fresh: true }), to);
48
48
  if (policy)
49
49
  assertPaneAllowed(policy, pane, "focus");
50
50
  // Zellij exits non-zero when asked to focus the already-focused pane.
@@ -104,7 +104,7 @@ export async function stackTargets(client, args, policy) {
104
104
  if (!list)
105
105
  throw new ZellijError("missing_peer", "to (comma list) required");
106
106
  const sess = await session(client, args);
107
- const panes = await client.listPanes(sess);
107
+ const panes = await client.listPanes(sess, undefined, { fresh: true });
108
108
  const ids = list
109
109
  .split(",")
110
110
  .map((k) => k.trim())
@@ -0,0 +1,13 @@
1
+ import type { ZellijTransport } from "../zellij/client.js";
2
+ export type RoutingContext = {
3
+ transport: "local" | "ssh" | "serve";
4
+ host: string;
5
+ session: string | null;
6
+ origin: {
7
+ transport: string;
8
+ session: string;
9
+ };
10
+ /** The serve endpoint can be a local tunnel; this identifies its server. */
11
+ server?: RoutingContext;
12
+ };
13
+ export declare function invocationContext(args: Record<string, unknown>, env: NodeJS.ProcessEnv, injected?: ZellijTransport): RoutingContext;
@@ -0,0 +1,21 @@
1
+ import { hostname } from "node:os";
2
+ import { sessionFromEnv } from "../zellij/session.js";
3
+ import { isTrue, optionalString } from "./util.js";
4
+ export function invocationContext(args, env, injected) {
5
+ const serve = optionalString(env.ZSWARM_SERVE);
6
+ const ssh = optionalString(env.ZSWARM_SSH);
7
+ // Serve forwards explicit arguments; inherited session defaults belong to
8
+ // the server, not the caller (which might only be a local tunnel).
9
+ const selected = sessionFromEnv(serve && !injected ? {} : env, optionalString(args.session));
10
+ return {
11
+ transport: injected?.kind ?? (serve ? "serve" : ssh ? "ssh" : "local"),
12
+ host: injected ? injected.host ?? hostname() : serve ?? ssh ?? hostname(),
13
+ session: selected?.session ?? null,
14
+ origin: {
15
+ transport: injected ? "injected" : isTrue(args.local) ? "--local"
16
+ : optionalString(args.ssh) ? "--ssh" : optionalString(args.serveAddress) ? "--serve" : serve ? "ZSWARM_SERVE"
17
+ : ssh ? "ZSWARM_SSH" : "default",
18
+ session: selected?.source ?? "unresolved",
19
+ },
20
+ };
21
+ }
@@ -1,5 +1,5 @@
1
1
  import { type GitClient } from "../git.js";
2
2
  import type { ZellijClient } from "../zellij/client.js";
3
- import type { OpsResult } from "./types.js";
4
- /** Open a new pane (or tab) and report which pane it became. */
5
- export declare function spawnPane(client: ZellijClient, args: Record<string, unknown>, git?: GitClient): Promise<OpsResult>;
3
+ import type { Clock, OpsResult } from "./types.js";
4
+ /** Create exactly once. Only observations are retried, never the create action. */
5
+ export declare function spawnPane(client: ZellijClient, args: Record<string, unknown>, git?: GitClient, clock?: Clock, signal?: AbortSignal): Promise<OpsResult>;