@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
@@ -10,17 +10,50 @@ import { busChanged, busOp, busScreens, busSnapshot, busWait } 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, 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
+ if (local && sshDest) {
35
+ throw new ZellijError("usage", "pass --local or --ssh, not both");
36
+ }
37
+ if (!local && !sshDest)
38
+ return base;
39
+ const env = { ...base };
40
+ if (local) {
41
+ delete env.ZSWARM_SSH;
42
+ delete env.ZSWARM_SSH_OPTS;
43
+ delete env.ZSWARM_SSH_MODE;
44
+ delete env.ZSWARM_SSH_BIN;
45
+ delete env.ZSWARM_TMP;
46
+ delete env.ZSWARM_REMOTE_BIN;
47
+ delete env.ZSWARM_REMOTE_SHELL;
48
+ delete env.ZSWARM_SERVE;
49
+ delete env.ZSWARM_SERVE_TOKEN;
50
+ return env;
51
+ }
52
+ env.ZSWARM_SSH = sshDest;
53
+ delete env.ZSWARM_SERVE;
54
+ delete env.ZSWARM_SERVE_TOKEN;
55
+ return env;
56
+ }
24
57
  /** Same default `wait` applies: match when a needle is given, else idle. */
25
58
  function waitMode(args) {
26
59
  const requested = typeof args.for === "string" ? args.for.trim() : "";
@@ -53,51 +86,80 @@ function assertSshGitAllowed(env, op, args) {
53
86
  : "uses local git";
54
87
  throw new ZellijError("ssh_git_unsupported", `${op} ${why}; ZSWARM_SSH only forwards Zellij. Run zswarm serve on the host that owns the session`);
55
88
  }
56
- async function resolveTarget(client, args, state, clock, env, policy, op) {
89
+ async function resolveTarget(client, args, state, clock, env, policy, op, signal) {
57
90
  const to = String(args.to ?? "").trim();
58
91
  if (!to)
59
92
  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);
93
+ const budget = observationBudget(clock, numberArg(args, "timeoutMs", 30_000, { min: 1, max: 900_000 }), signal);
94
+ const observeMs = numberArg(args, "observeMs", 1_000, { min: 0, max: 30_000 });
95
+ const { session } = await client.resolveSession(optionalString(args.session), budget.require());
96
+ const bus = isVerbose(args) ? null : await busSnapshot(client, state, session, clock, env, "status", {
97
+ deadlineAt: budget.deadline, signal,
98
+ });
66
99
  let panes = null;
67
100
  if (bus) {
68
- const busPanes = busToPanes(bus.snapshot);
101
+ const snapshot = busToPanes(bus.snapshot);
69
102
  try {
70
- client.resolvePane(busPanes, to);
71
- panes = busPanes;
103
+ client.resolvePane(snapshot, to);
104
+ panes = snapshot;
72
105
  }
73
- catch {
74
- // A command-shaped `to` only matches the polled list.
106
+ catch { /* An absent title/command/ID gets a fresh direct observation. */ }
107
+ }
108
+ const until = Math.min(budget.deadline, clock.now() + observeMs);
109
+ let pane;
110
+ for (;;) {
111
+ const left = observeMs === 0 ? budget.require() : Math.min(budget.require(), Math.max(1, until - clock.now()));
112
+ panes ??= await client.listPanes(session, left);
113
+ throwIfAborted(signal);
114
+ try {
115
+ pane = client.resolvePane(panes, to);
116
+ break;
117
+ }
118
+ catch (err) {
119
+ if (!(err instanceof ZellijError) || err.code !== "peer_not_found" || clock.now() >= until)
120
+ throw err;
121
+ await budget.pause(until);
122
+ if (clock.now() >= until)
123
+ throw err;
124
+ panes = null;
75
125
  }
76
126
  }
77
- if (!panes)
78
- panes = await client.listPanes(session);
79
- const pane = client.resolvePane(panes, to);
80
127
  if (policy && op)
81
128
  assertPaneAllowed(policy, pane, op);
82
129
  return { session, panes, pane };
83
130
  }
84
- /** Shared MCP/CLI dispatch for zswarm ops. */
85
- export async function dispatchZswarm(args, injected, deps = {}) {
131
+ async function dispatchOperation(args, injected, deps, context, env) {
86
132
  const op = String(args.op ?? "");
87
133
  const verbose = isVerbose(args);
134
+ const signal = deps.signal;
88
135
  const clock = {
89
136
  now: deps.now ?? (() => Date.now()),
90
137
  sleep: deps.sleep ??
91
- ((ms) => new Promise((r) => setTimeout(r, ms))),
138
+ ((ms) => new Promise((resolve, reject) => {
139
+ if (signal?.aborted) {
140
+ reject(new ZellijError("cancelled", "operation cancelled"));
141
+ return;
142
+ }
143
+ const timer = setTimeout(() => {
144
+ signal?.removeEventListener("abort", onAbort);
145
+ resolve();
146
+ }, ms);
147
+ const onAbort = () => {
148
+ clearTimeout(timer);
149
+ signal?.removeEventListener("abort", onAbort);
150
+ reject(new ZellijError("cancelled", "operation cancelled"));
151
+ };
152
+ signal?.addEventListener("abort", onAbort, { once: true });
153
+ })),
92
154
  };
93
155
  // Only the worktree ops need git, so the client is built on demand.
94
156
  let gitClient = deps.git ?? null;
95
157
  const git = () => (gitClient ??= createGitClient());
96
158
  let stateStore = deps.state ?? null;
97
- const state = () => (stateStore ??= createStateStore());
98
- const policy = deps.policy ?? loadPolicy(deps.env);
99
- const env = deps.env ?? process.env;
159
+ const state = () => (stateStore ??= createStateStore({ env }));
100
160
  try {
161
+ throwIfAborted(signal);
162
+ const policy = deps.policy ?? loadPolicy(env);
101
163
  // Policy gates the op before anything touches the session.
102
164
  assertOpAllowed(policy, op);
103
165
  assertSshGitAllowed(env, op, args);
@@ -121,11 +183,42 @@ export async function dispatchZswarm(args, injected, deps = {}) {
121
183
  if (!injected && env.ZSWARM_SERVE?.trim()) {
122
184
  return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
123
185
  }
124
- const client = injected ?? createZellijClient({ env: deps.env });
186
+ const baseClient = injected ?? createZellijClient({ env, signal });
187
+ const client = {
188
+ ...baseClient,
189
+ get transport() { return baseClient.transport; },
190
+ async resolveSession(...input) {
191
+ const resolved = await baseClient.resolveSession(...input);
192
+ context.session = resolved.session;
193
+ context.origin.session = resolved.source;
194
+ return resolved;
195
+ },
196
+ };
125
197
  switch (op) {
126
198
  case "sessions": {
127
- const sessions = await client.listSessions();
128
- return { ok: true, data: { sessions, zellij: client.zellijPath } };
199
+ // Resolve SSH/IPC first so a Windows interactive crew is reachable
200
+ // before --live filters EXITED rows.
201
+ const listed = await client.listSessions();
202
+ const transport = client.transport;
203
+ const ipc = transport.ipc;
204
+ if (ipc &&
205
+ ipc.requested?.toLowerCase() === "auto" &&
206
+ (ipc.status === "failed" ||
207
+ ipc.status === "expired" ||
208
+ ipc.status === "cancelled")) {
209
+ 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".`);
210
+ }
211
+ const includeExited = isTrue(args.all);
212
+ const sessions = includeExited
213
+ ? listed
214
+ : listed.filter((s) => !s.exited);
215
+ const data = {
216
+ sessions,
217
+ zellij: client.zellijPath,
218
+ transport,
219
+ filter: includeExited ? "all" : "live",
220
+ };
221
+ return { ok: true, data };
129
222
  }
130
223
  case "list": {
131
224
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
@@ -133,7 +226,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
133
226
  // verbose has to go the polling route.
134
227
  const bus = verbose
135
228
  ? null
136
- : await busSnapshot(client, state(), session, clock, deps.env);
229
+ : await busSnapshot(client, state(), session, clock, env);
137
230
  const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
138
231
  const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
139
232
  .filter((p) => !p.isPlugin)
@@ -152,16 +245,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
152
245
  const body = String(args.body ?? args.text ?? "");
153
246
  if (!body.trim())
154
247
  throw new ZellijError("missing_body", "body required");
155
- const { session, pane, panes } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
248
+ const { session, pane, panes } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
156
249
  assertWritable(client, pane, args, "send");
157
- // A pane that dropped back to a shell will *run* the message. There is
158
- // no reliable way to tell an agent from a prompt, so the caller names
159
- // something the screen must show first.
160
- const expect = typeof args.expect === "string" ? args.expect.trim() : "";
161
- if (expect) {
162
- const screen = await client.dumpPane({ session, paneId: pane.id });
163
- assertPaneExpects(screen.text, expect, pane.id);
164
- }
250
+ await assertExpectedScreen(client, session, pane.id, args, signal);
165
251
  const labeled = withSenderLabel(args, {
166
252
  env,
167
253
  selfTitle: selfPaneTitle(client, panes),
@@ -192,8 +278,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
192
278
  return await broadcast(client, state(), args, clock, policy, env);
193
279
  case "keys":
194
280
  case "interrupt": {
195
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
281
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
196
282
  assertWritable(client, pane, args, op);
283
+ await assertExpectedScreen(client, session, pane.id, args, signal);
197
284
  const chars = typeof args.chars === "string" ? args.chars : "";
198
285
  if (op === "keys" && chars) {
199
286
  await client.writeChars({ session, paneId: pane.id, chars });
@@ -246,7 +333,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
246
333
  };
247
334
  }
248
335
  case "dump": {
249
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env);
336
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
250
337
  const dumped = await client.dumpPane({
251
338
  session,
252
339
  paneId: pane.id,
@@ -268,41 +355,67 @@ export async function dispatchZswarm(args, injected, deps = {}) {
268
355
  };
269
356
  }
270
357
  case "tail": {
271
- const target = await resolveTarget(client, args, state(), clock, deps.env);
358
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
272
359
  return await tailPane(client, state(), args, target);
273
360
  }
274
361
  case "wait": {
275
- const target = await resolveTarget(client, args, state(), clock, deps.env);
362
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
276
363
  // The plugin holds one pipe for the whole wait and polls far tighter
277
364
  // than spawning a process allows. It declines a regex needle — it has
278
365
  // no engine — and then this is the loop that runs.
279
366
  const viaBus = isTrue(args.regex)
280
367
  ? undefined
281
- : () => busWait(client, state(), target.session, {
368
+ : (timing) => busWait(client, state(), target.session, {
282
369
  pane: target.pane.id,
283
370
  for: waitMode(args),
284
371
  match: typeof args.match === "string" ? args.match : null,
285
372
  ignoreCase: isTrue(args.ignoreCase),
286
- idleMs: Number(args.idleMs ?? 2000),
287
- timeoutMs: Number(args.timeoutMs ?? 60_000),
288
- }, deps.env);
289
- return await waitForPane(client, target, args, clock, viaBus);
373
+ ...timing,
374
+ }, env);
375
+ return await waitForPane(client, target, args, clock, viaBus, deps.signal);
290
376
  }
291
377
  case "status": {
292
- const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
378
+ // One overall deadline covering session resolve, bus snapshot, IPC,
379
+ // and screen samples — not just the dump loop inside peerStatus.
380
+ const statusBudget = numberArg(args, "timeoutMs", DEFAULT_STATUS_TIMEOUT_MS, { min: 1_000, max: 900_000 });
381
+ const deadlineAt = clock.now() + statusBudget;
382
+ const remaining = () => Math.max(0, deadlineAt - clock.now());
383
+ const setupBudget = () => {
384
+ throwIfAborted(signal);
385
+ const left = remaining();
386
+ if (left <= 0) {
387
+ throw new ZellijError("zellij_failed", "status timed out during setup");
388
+ }
389
+ return left;
390
+ };
391
+ throwIfAborted(signal);
392
+ const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget());
393
+ throwIfAborted(signal);
293
394
  // Verbose reports cwd and command, which the plugin manifest lacks.
294
- const bus = verbose
395
+ const bus = verbose || remaining() <= 0
295
396
  ? null
296
- : await busSnapshot(client, state(), session, clock, deps.env);
297
- let supplied = bus
298
- ? {
299
- session,
300
- panes: busToPanes(bus.snapshot),
301
- source: "plugin",
302
- // Sampling is 2N dumps; the plugin reads them all in one pipe.
303
- readScreens: (paneIds) => busScreens(client, state(), session, paneIds, clock, deps.env),
304
- readChanged: async (paneIds) => {
305
- const reply = await busChanged(client, state(), session, paneIds, deps.env);
397
+ : await busSnapshot(client, state(), session, clock, env, "status", {
398
+ deadlineAt,
399
+ signal,
400
+ });
401
+ throwIfAborted(signal);
402
+ // Always hand peerStatus the resolved session (and panes) so it does
403
+ // not spend the overall budget resolving again.
404
+ const panes = bus
405
+ ? busToPanes(bus.snapshot)
406
+ : await client.listPanes(session, setupBudget());
407
+ throwIfAborted(signal);
408
+ let supplied = {
409
+ session,
410
+ panes,
411
+ source: bus ? "plugin" : "zellij",
412
+ };
413
+ if (bus) {
414
+ supplied = {
415
+ ...supplied,
416
+ readScreens: (paneIds, timeoutMs) => busScreens(client, state(), session, paneIds, clock, env, timeoutMs),
417
+ readChanged: async (paneIds, timeoutMs) => {
418
+ const reply = await busChanged(client, state(), session, paneIds, env, timeoutMs);
306
419
  if (!reply)
307
420
  return null;
308
421
  return new Map(reply.panes.map((p) => [
@@ -310,23 +423,26 @@ export async function dispatchZswarm(args, injected, deps = {}) {
310
423
  { changed: p.changed, first: p.first, screen: p.screen },
311
424
  ]));
312
425
  },
313
- }
314
- : null;
426
+ };
427
+ }
315
428
  const only = typeof args.to === "string" ? args.to.trim() : "";
316
- if (supplied && only) {
429
+ if (bus && only) {
317
430
  try {
318
431
  client.resolvePane(supplied.panes, only);
319
432
  }
320
433
  catch {
321
- // The manifest has no commands, so a command-shaped `to` only
322
- // resolves against the polled list.
323
- supplied = null;
434
+ // Command-shaped `to` needs the polled list; re-fetch without bus.
435
+ const polled = await client.listPanes(session, setupBudget());
436
+ supplied = { session, panes: polled, source: "zellij" };
324
437
  }
325
438
  }
326
- return await peerStatus(client, args, clock, supplied);
439
+ return await peerStatus(client, args, clock, supplied, {
440
+ deadlineAt,
441
+ signal,
442
+ });
327
443
  }
328
444
  case "bus":
329
- return await busOp(client, state(), args, clock, deps.env, policy);
445
+ return await busOp(client, state(), args, clock, env, policy);
330
446
  case "signal":
331
447
  return postSignal(state(), args, clock);
332
448
  case "signals":
@@ -350,13 +466,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
350
466
  case "checkpoint":
351
467
  return await peerCheckpoint(git(), args, clock);
352
468
  case "spawn":
353
- return await spawnPane(client, args, deps.git);
469
+ return await spawnPane(client, args, deps.git, clock, signal);
354
470
  case "worktrees":
355
471
  return await listPeerWorktrees(git(), client, args);
356
472
  case "unworktree":
357
473
  return await removePeerWorktree(git(), client, args);
358
474
  case "close": {
359
- const { session, pane } = await resolveTarget(client, args, state(), clock, deps.env, policy, op);
475
+ const { session, pane } = await resolveTarget(client, args, state(), clock, env, policy, op, signal);
360
476
  // Not assertWritable: closing an exited pane is the point of close.
361
477
  assertNotPlugin(pane, "close");
362
478
  assertNotSelf(client, pane, args, "close");
@@ -381,3 +497,27 @@ export async function dispatchZswarm(args, injected, deps = {}) {
381
497
  return fail(err);
382
498
  }
383
499
  }
500
+ /** Shared MCP/CLI dispatch, with invocation routing on successes and failures. */
501
+ export async function dispatchZswarm(args, injected, deps = {}) {
502
+ try {
503
+ const env = resolveInvocationEnv(args, deps.env ?? process.env);
504
+ const context = invocationContext(args, env, injected?.transport);
505
+ const result = await dispatchOperation(args, injected, deps, context, env);
506
+ if (context.transport === "serve" && result.context) {
507
+ context.server = result.context;
508
+ context.session = result.context.session;
509
+ context.origin.session = result.context.origin.session;
510
+ }
511
+ else if (context.transport === "serve" && result.ok && result.data && typeof result.data === "object") {
512
+ const session = result.data.session;
513
+ if (typeof session === "string") {
514
+ context.session = session;
515
+ context.origin.session = "server";
516
+ }
517
+ }
518
+ return { ...result, context };
519
+ }
520
+ catch (err) {
521
+ return fail(err);
522
+ }
523
+ }
@@ -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
+ }
@@ -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" : serve ? "ZSWARM_SERVE"
17
+ : ssh ? "ZSWARM_SSH" : "default",
18
+ session: selected?.source ?? "unresolved",
19
+ },
20
+ };
21
+ }
@@ -36,6 +36,7 @@ export declare function parseServeTarget(raw: string): {
36
36
  port: number;
37
37
  };
38
38
  export declare function callServe(target: string, args: Record<string, unknown>, timeoutMs?: number, token?: string): Promise<OpsResult>;
39
+ export declare function redactServeSecret(command: string, token: string): string;
39
40
  export declare function serveLogonCommand(execPath: string, scriptPath: string, listen: string, token?: string): string;
40
41
  export declare function installServeLogon(input: {
41
42
  listen?: string;
package/dist/ops/serve.js CHANGED
@@ -252,6 +252,12 @@ export function callServe(target, args, timeoutMs = 15_000, token) {
252
252
  });
253
253
  });
254
254
  }
255
+ export function redactServeSecret(command, token) {
256
+ const secret = token.trim();
257
+ if (!secret)
258
+ return command;
259
+ return command.split(secret).join("***");
260
+ }
255
261
  export function serveLogonCommand(execPath, scriptPath, listen, token) {
256
262
  const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
257
263
  const secret = token?.trim();
@@ -323,7 +329,7 @@ export async function installServeLogon(input) {
323
329
  if (result.code !== 0) {
324
330
  throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
325
331
  }
326
- return { task: SERVE_TASK_NAME, listen: label, command };
332
+ return { task: SERVE_TASK_NAME, listen: label, command: redactServeSecret(command, token) };
327
333
  }
328
334
  export async function uninstallServeLogon(input = {}) {
329
335
  if ((input.platform ?? process.platform) !== "win32") {
@@ -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>;