@zswarm/core 0.1.5 → 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.
@@ -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, 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
+ 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,40 +86,52 @@ 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);
88
134
  const signal = deps.signal;
89
- throwIfAborted(signal);
90
135
  const clock = {
91
136
  now: deps.now ?? (() => Date.now()),
92
137
  sleep: deps.sleep ??
@@ -95,9 +140,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
95
140
  reject(new ZellijError("cancelled", "operation cancelled"));
96
141
  return;
97
142
  }
98
- const timer = setTimeout(resolve, ms);
143
+ const timer = setTimeout(() => {
144
+ signal?.removeEventListener("abort", onAbort);
145
+ resolve();
146
+ }, ms);
99
147
  const onAbort = () => {
100
148
  clearTimeout(timer);
149
+ signal?.removeEventListener("abort", onAbort);
101
150
  reject(new ZellijError("cancelled", "operation cancelled"));
102
151
  };
103
152
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -107,10 +156,10 @@ export async function dispatchZswarm(args, injected, deps = {}) {
107
156
  let gitClient = deps.git ?? null;
108
157
  const git = () => (gitClient ??= createGitClient());
109
158
  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;
159
+ const state = () => (stateStore ??= createStateStore({ env }));
113
160
  try {
161
+ throwIfAborted(signal);
162
+ const policy = deps.policy ?? loadPolicy(env);
114
163
  // Policy gates the op before anything touches the session.
115
164
  assertOpAllowed(policy, op);
116
165
  assertSshGitAllowed(env, op, args);
@@ -134,11 +183,42 @@ export async function dispatchZswarm(args, injected, deps = {}) {
134
183
  if (!injected && env.ZSWARM_SERVE?.trim()) {
135
184
  return await callServe(env.ZSWARM_SERVE.trim(), attachKnownSender(args, env), serveCallTimeout(args), env.ZSWARM_SERVE_TOKEN);
136
185
  }
137
- const client = injected ?? createZellijClient({ env: deps.env, signal });
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
+ };
138
197
  switch (op) {
139
198
  case "sessions": {
140
- const sessions = await client.listSessions();
141
- 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 };
142
222
  }
143
223
  case "list": {
144
224
  const { session } = await client.resolveSession(typeof args.session === "string" ? args.session : undefined);
@@ -146,7 +226,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
146
226
  // verbose has to go the polling route.
147
227
  const bus = verbose
148
228
  ? null
149
- : await busSnapshot(client, state(), session, clock, deps.env);
229
+ : await busSnapshot(client, state(), session, clock, env);
150
230
  const view = bus ? paneViewBus : verbose ? paneViewFull : paneViewSlim;
151
231
  const panes = (bus ? busToPanes(bus.snapshot) : await client.listPanes(session))
152
232
  .filter((p) => !p.isPlugin)
@@ -165,16 +245,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
165
245
  const body = String(args.body ?? args.text ?? "");
166
246
  if (!body.trim())
167
247
  throw new ZellijError("missing_body", "body required");
168
- 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);
169
249
  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
- }
250
+ await assertExpectedScreen(client, session, pane.id, args, signal);
178
251
  const labeled = withSenderLabel(args, {
179
252
  env,
180
253
  selfTitle: selfPaneTitle(client, panes),
@@ -205,8 +278,9 @@ export async function dispatchZswarm(args, injected, deps = {}) {
205
278
  return await broadcast(client, state(), args, clock, policy, env);
206
279
  case "keys":
207
280
  case "interrupt": {
208
- 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);
209
282
  assertWritable(client, pane, args, op);
283
+ await assertExpectedScreen(client, session, pane.id, args, signal);
210
284
  const chars = typeof args.chars === "string" ? args.chars : "";
211
285
  if (op === "keys" && chars) {
212
286
  await client.writeChars({ session, paneId: pane.id, chars });
@@ -259,7 +333,7 @@ export async function dispatchZswarm(args, injected, deps = {}) {
259
333
  };
260
334
  }
261
335
  case "dump": {
262
- 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);
263
337
  const dumped = await client.dumpPane({
264
338
  session,
265
339
  paneId: pane.id,
@@ -281,11 +355,11 @@ export async function dispatchZswarm(args, injected, deps = {}) {
281
355
  };
282
356
  }
283
357
  case "tail": {
284
- const target = await resolveTarget(client, args, state(), clock, deps.env);
358
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
285
359
  return await tailPane(client, state(), args, target);
286
360
  }
287
361
  case "wait": {
288
- const target = await resolveTarget(client, args, state(), clock, deps.env);
362
+ const target = await resolveTarget(client, args, state(), clock, env, undefined, undefined, signal);
289
363
  // The plugin holds one pipe for the whole wait and polls far tighter
290
364
  // than spawning a process allows. It declines a regex needle — it has
291
365
  // no engine — and then this is the loop that runs.
@@ -297,24 +371,51 @@ export async function dispatchZswarm(args, injected, deps = {}) {
297
371
  match: typeof args.match === "string" ? args.match : null,
298
372
  ignoreCase: isTrue(args.ignoreCase),
299
373
  ...timing,
300
- }, deps.env);
374
+ }, env);
301
375
  return await waitForPane(client, target, args, clock, viaBus, deps.signal);
302
376
  }
303
377
  case "status": {
304
- 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);
305
394
  // Verbose reports cwd and command, which the plugin manifest lacks.
306
- const bus = verbose
395
+ const bus = verbose || remaining() <= 0
307
396
  ? 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);
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);
318
419
  if (!reply)
319
420
  return null;
320
421
  return new Map(reply.panes.map((p) => [
@@ -322,23 +423,26 @@ export async function dispatchZswarm(args, injected, deps = {}) {
322
423
  { changed: p.changed, first: p.first, screen: p.screen },
323
424
  ]));
324
425
  },
325
- }
326
- : null;
426
+ };
427
+ }
327
428
  const only = typeof args.to === "string" ? args.to.trim() : "";
328
- if (supplied && only) {
429
+ if (bus && only) {
329
430
  try {
330
431
  client.resolvePane(supplied.panes, only);
331
432
  }
332
433
  catch {
333
- // The manifest has no commands, so a command-shaped `to` only
334
- // resolves against the polled list.
335
- 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" };
336
437
  }
337
438
  }
338
- return await peerStatus(client, args, clock, supplied);
439
+ return await peerStatus(client, args, clock, supplied, {
440
+ deadlineAt,
441
+ signal,
442
+ });
339
443
  }
340
444
  case "bus":
341
- return await busOp(client, state(), args, clock, deps.env, policy);
445
+ return await busOp(client, state(), args, clock, env, policy);
342
446
  case "signal":
343
447
  return postSignal(state(), args, clock);
344
448
  case "signals":
@@ -362,13 +466,13 @@ export async function dispatchZswarm(args, injected, deps = {}) {
362
466
  case "checkpoint":
363
467
  return await peerCheckpoint(git(), args, clock);
364
468
  case "spawn":
365
- return await spawnPane(client, args, deps.git);
469
+ return await spawnPane(client, args, deps.git, clock, signal);
366
470
  case "worktrees":
367
471
  return await listPeerWorktrees(git(), client, args);
368
472
  case "unworktree":
369
473
  return await removePeerWorktree(git(), client, args);
370
474
  case "close": {
371
- 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);
372
476
  // Not assertWritable: closing an exited pane is the point of close.
373
477
  assertNotPlugin(pane, "close");
374
478
  assertNotSelf(client, pane, args, "close");
@@ -393,3 +497,27 @@ export async function dispatchZswarm(args, injected, deps = {}) {
393
497
  return fail(err);
394
498
  }
395
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
+ }
@@ -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>;