@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.
@@ -1,5 +1,6 @@
1
+ import { ZellijError } from "../errors.js";
1
2
  import { resolveHarness } from "../harness.js";
2
- import { isTrue, normalizeScreen, numberArg, optionalString } from "./util.js";
3
+ import { isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, } from "./util.js";
3
4
  /**
4
5
  * Legacy last-line prompt shapes for callers without a profile. A term counts
5
6
  * only when it is a question, not chrome: a y/n form, a trailing `?`, or the
@@ -18,6 +19,10 @@ const QUESTION = /(\(y\/n\)|\[y\/n\]|\(yes\/no\)|\[y\/n\/a\]|password:|passphras
18
19
  * where it is or it would fire on chrome.
19
20
  */
20
21
  const PROMPT_WINDOW = 24;
22
+ /** Default overall budget for a sampled status pass (covers IPC + dumps). */
23
+ export const DEFAULT_STATUS_TIMEOUT_MS = 30_000;
24
+ /** How many dump-screen calls to run at once on the direct (non-bus) path. */
25
+ export const STATUS_DUMP_CONCURRENCY = 3;
21
26
  export function lastLine(screen) {
22
27
  const lines = screen.split("\n").filter((l) => l.trim());
23
28
  return lines.length > 0 ? lines[lines.length - 1].trim() : "";
@@ -30,25 +35,39 @@ function trailingLines(screen, n) {
30
35
  .filter((line) => line.length > 0)
31
36
  .slice(-n);
32
37
  }
33
- /**
34
- * True when the trailing window holds a named approval prompt. The profile's
35
- * waiting patterns run across the window; the legacy last-line QUESTION check
36
- * stays as the conservative fallback for callers without a profile. Bias is
37
- * toward false negatives: a hit must be prompt UI we can name, never a bare
38
- * question mark or "confirm".
39
- */
40
- function promptHolds(screen, profile) {
38
+ export function waitingPrompt(screen, profile) {
41
39
  const lines = trailingLines(screen, PROMPT_WINDOW);
42
- const patterns = profile?.waiting;
43
- if (patterns && patterns.length > 0) {
44
- for (const line of lines) {
45
- for (const re of patterns) {
46
- if (re.test(line))
47
- return true;
48
- }
40
+ // A menu requires a choice structure AND navigation chrome. A lone "Allow
41
+ // once" in logs/help must not turn a worker into an approval request.
42
+ const choices = lines.filter((line) => /^(?:[›❯>●]\s*)?\d+[.)]\s+(?:Yes\b|No\b|Allow\b|Deny\b|Cancel\b)/i.test(line));
43
+ const navigation = lines.some((line) => /(?:enter|return)\s+(?:to\s+)?(?:select|confirm)|(?:esc|escape)\s+(?:to\s+)?cancel/i.test(line));
44
+ const approval = lines.find((line) => /^(?:[›❯>]\s*)?(?:Approve (?:this|the) (?:operation|command|action)|Approval required|Would you like to run the following command\?|Run this command\?)[?:]?$/i.test(line));
45
+ if (approval && choices.length >= 2 && navigation) {
46
+ return { reason: "approval_menu", evidence: choices.slice(0, 3).join("\n").slice(0, 320), source: "screen" };
47
+ }
48
+ for (const line of lines) {
49
+ if (profile?.waiting.some((re) => re.test(line))) {
50
+ return { reason: "prompt", evidence: line.slice(0, 320), source: "screen" };
49
51
  }
50
52
  }
51
- return QUESTION.test(lines[lines.length - 1] ?? "");
53
+ const last = lines[lines.length - 1] ?? "";
54
+ return QUESTION.test(last) ? { reason: "prompt", evidence: last.slice(0, 320), source: "screen" } : null;
55
+ }
56
+ function promptHolds(screen, profile) {
57
+ return waitingPrompt(screen, profile) !== null;
58
+ }
59
+ /** Compact summary of the selected terminal peers, including inactive tabs. */
60
+ export function statusTabs(peers) {
61
+ const tabs = new Map();
62
+ for (const peer of peers) {
63
+ const key = JSON.stringify([peer.tabId, peer.tab]);
64
+ const tab = tabs.get(key) ?? { id: peer.tabId, name: peer.tab, panes: 0, states: {} };
65
+ tab.panes++;
66
+ const state = String(peer.state);
67
+ tab.states[state] = (tab.states[state] ?? 0) + 1;
68
+ tabs.set(key, tab);
69
+ }
70
+ return [...tabs.values()];
52
71
  }
53
72
  export function classify(input) {
54
73
  if (input.exited)
@@ -57,72 +76,114 @@ export function classify(input) {
57
76
  return "busy";
58
77
  return promptHolds(input.after, input.profile) ? "waiting" : "idle";
59
78
  }
79
+ /** Run `fn` over items with at most `limit` in flight. */
80
+ export async function mapPool(items, limit, fn) {
81
+ if (items.length === 0)
82
+ return [];
83
+ const concurrency = Math.max(1, Math.min(limit, items.length));
84
+ const results = new Array(items.length);
85
+ let next = 0;
86
+ async function worker() {
87
+ while (next < items.length) {
88
+ const i = next++;
89
+ results[i] = await fn(items[i], i);
90
+ }
91
+ }
92
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
93
+ return results;
94
+ }
95
+ function isCancelledError(err) {
96
+ return ((err instanceof ZellijError && err.code === "cancelled") ||
97
+ (err instanceof Error && /cancelled/i.test(err.message)));
98
+ }
99
+ function peerEntry(pane, state, screen, verbose, extra) {
100
+ const entry = {
101
+ id: pane.id,
102
+ title: pane.title,
103
+ tab: pane.tabName ?? null,
104
+ tabId: pane.tabId ?? null,
105
+ state,
106
+ lastLine: lastLine(screen).slice(0, 160),
107
+ ...extra,
108
+ };
109
+ if (state === "waiting")
110
+ entry.waiting = waitingPrompt(screen, resolveHarness(pane));
111
+ if (verbose) {
112
+ entry.command = pane.command ?? null;
113
+ entry.cwd = pane.cwd ?? null;
114
+ }
115
+ return entry;
116
+ }
60
117
  /**
61
118
  * Sample every pane twice and say who is working, who is stuck on a prompt,
62
119
  * and who is free — the routing question `list` cannot answer.
120
+ *
121
+ * `deadlineAt` is the absolute clock deadline for the whole status op (setup
122
+ * included). When omitted, one is derived from `timeoutMs` at entry.
63
123
  */
64
- export async function peerStatus(client, args, clock, supplied) {
124
+ export async function peerStatus(client, args, clock, supplied, opts = {}) {
125
+ const signal = opts.signal;
126
+ throwIfAborted(signal);
127
+ const budget = numberArg(args, "timeoutMs", DEFAULT_STATUS_TIMEOUT_MS, {
128
+ min: 1_000,
129
+ max: 900_000,
130
+ });
131
+ const deadline = opts.deadlineAt ?? clock.now() + budget;
132
+ const remaining = () => Math.max(0, deadline - clock.now());
133
+ const setupBudget = () => {
134
+ throwIfAborted(signal);
135
+ const left = remaining();
136
+ if (left <= 0) {
137
+ throw new ZellijError("zellij_failed", "status timed out during setup");
138
+ }
139
+ return left;
140
+ };
141
+ // Prefer the caller's already-resolved session/panes so dispatch's setup
142
+ // budget is not spent twice.
65
143
  const session = supplied?.session ??
66
- (await client.resolveSession(typeof args.session === "string" ? args.session : undefined)).session;
67
- const panes = supplied?.panes ?? (await client.listPanes(session));
144
+ (await client.resolveSession(typeof args.session === "string" ? args.session : undefined, setupBudget())).session;
145
+ throwIfAborted(signal);
146
+ const panes = supplied?.panes ?? (await client.listPanes(session, setupBudget()));
147
+ throwIfAborted(signal);
68
148
  const source = supplied?.source ?? "zellij";
69
149
  const only = optionalString(args.to);
150
+ const verbose = isTrue(args.verbose);
70
151
  const targets = only
71
152
  ? [client.resolvePane(panes, only)]
72
153
  : panes.filter((p) => !p.isPlugin);
73
- // sampleMs=0 asks for the cheap answer: who is alive, from state the plugin
74
- // already holds. Below that, two samples too close together read as idle.
75
154
  const requested = numberArg(args, "sampleMs", 400, { min: 0, max: 10_000 });
76
155
  if (requested === 0) {
77
156
  const peers = targets
78
- .map((pane) => {
79
- const entry = {
80
- id: pane.id,
81
- title: pane.title,
82
- state: pane.exited ? "exited" : "running",
83
- };
84
- if (isTrue(args.verbose)) {
85
- entry.command = pane.command ?? null;
86
- entry.cwd = pane.cwd ?? null;
87
- entry.tab = pane.tabName ?? null;
88
- }
89
- return entry;
90
- })
157
+ .map((pane) => peerEntry(pane, pane.exited ? "exited" : "running", "", verbose))
91
158
  .sort((a, b) => String(a.id).localeCompare(String(b.id)));
92
- // No `free`: without sampling there is no way to tell busy from idle.
93
159
  return {
94
160
  ok: true,
95
- data: { session, source, sampled: false, sampleMs: 0, peers },
161
+ data: { session, source, sampled: false, sampleMs: 0, peers, tabs: statusTabs(peers) },
96
162
  };
97
163
  }
98
164
  const sampleMs = Math.max(50, requested);
99
- const live = targets.filter((pane) => !pane.exited).map((pane) => pane.id);
100
- // sinceLast trades the fixed 400ms window for "moved since your last call".
101
- // The plugin remembers the previous screen, so there is no gap to wait out —
102
- // but ask twice in quick succession and everything reads idle.
165
+ const live = targets.filter((pane) => !pane.exited);
103
166
  if (isTrue(args.sinceLast) && supplied?.readChanged) {
104
- const changed = await supplied.readChanged(live);
167
+ const left = remaining();
168
+ const changed = left > 0 ? await supplied.readChanged(live.map((p) => p.id), left) : null;
169
+ throwIfAborted(signal);
105
170
  if (changed) {
106
171
  const peers = targets
107
172
  .map((pane) => {
173
+ if (pane.exited) {
174
+ return peerEntry(pane, "exited", "", verbose);
175
+ }
108
176
  const row = changed.get(pane.id);
109
- const state = pane.exited
110
- ? "exited"
111
- : row?.changed
177
+ const state = !row
178
+ ? "unknown"
179
+ : row.changed
112
180
  ? "busy"
113
181
  : promptHolds(row?.screen ?? "", resolveHarness(pane))
114
182
  ? "waiting"
115
183
  : "idle";
116
- const entry = {
117
- id: pane.id,
118
- title: pane.title,
119
- state,
120
- lastLine: lastLine(row?.screen ?? "").slice(0, 160),
121
- };
122
- // First sight has nothing to compare against, so idle is a guess.
123
- if (row?.first)
124
- entry.first = true;
125
- return entry;
184
+ return peerEntry(pane, state, row?.screen ?? "", verbose, {
185
+ ...(row?.first ? { first: true } : {}),
186
+ });
126
187
  })
127
188
  .sort((a, b) => String(a.id).localeCompare(String(b.id)));
128
189
  return {
@@ -133,60 +194,139 @@ export async function peerStatus(client, args, clock, supplied) {
133
194
  sampled: false,
134
195
  sinceLast: true,
135
196
  peers,
197
+ tabs: statusTabs(peers),
136
198
  free: peers.filter((p) => p.state === "idle").map((p) => p.id),
137
199
  },
138
200
  };
139
201
  }
140
202
  }
141
203
  /**
142
- * One batched read when the bus can serve it, otherwise a process per pane.
143
- * Both paths normalize, which is what makes them comparable: the plugin pads
144
- * lines to the terminal width and `dump-screen` does not.
204
+ * Bus path: one batched read per round, each bounded by the remaining budget.
205
+ * Direct path: each pane completes its own before→sleep→after pair under a
206
+ * shared concurrency limit so one stall does not block healthy peers.
145
207
  */
146
- const sample = async () => {
147
- const batched = supplied?.readScreens
148
- ? await supplied.readScreens(live)
149
- : null;
150
- if (batched) {
151
- return new Map([...batched].map(([id, text]) => [id, normalizeScreen(text)]));
208
+ const samples = new Map();
209
+ if (supplied?.readScreens) {
210
+ const sampleRound = async () => {
211
+ throwIfAborted(signal);
212
+ const left = remaining();
213
+ if (left <= 0) {
214
+ return new Map(live.map((p) => [p.id, null]));
215
+ }
216
+ try {
217
+ const batched = await supplied.readScreens(live.map((p) => p.id), left);
218
+ throwIfAborted(signal);
219
+ if (!batched)
220
+ return new Map(); // signal fallback to dumps below
221
+ return new Map(live.map((p) => {
222
+ const text = batched.get(p.id);
223
+ return [
224
+ p.id,
225
+ text === undefined ? null : normalizeScreen(text),
226
+ ];
227
+ }));
228
+ }
229
+ catch (err) {
230
+ if (isCancelledError(err) || signal?.aborted) {
231
+ throw new ZellijError("cancelled", "operation cancelled");
232
+ }
233
+ return new Map(live.map((p) => [p.id, null]));
234
+ }
235
+ };
236
+ let before = await sampleRound();
237
+ if (before.size === 0) {
238
+ // Bus declined — fall through to per-pane dumps.
239
+ before = new Map();
152
240
  }
153
- const screens = new Map();
154
- for (const id of live) {
155
- const dumped = await client.dumpPane({ session, paneId: id });
156
- screens.set(id, normalizeScreen(dumped.text));
241
+ else {
242
+ let after = new Map();
243
+ if (remaining() > sampleMs) {
244
+ await clock.sleep(sampleMs);
245
+ throwIfAborted(signal);
246
+ after = await sampleRound();
247
+ }
248
+ for (const pane of live) {
249
+ samples.set(pane.id, {
250
+ before: before.get(pane.id) ?? null,
251
+ after: after.get(pane.id) ?? null,
252
+ });
253
+ }
157
254
  }
158
- return screens;
159
- };
160
- const before = await sample();
161
- await clock.sleep(sampleMs);
162
- const afterScreens = await sample();
255
+ }
256
+ if (samples.size === 0) {
257
+ // Per-pane sample pairs under bounded concurrency.
258
+ await mapPool(live, STATUS_DUMP_CONCURRENCY, async (pane) => {
259
+ throwIfAborted(signal);
260
+ const dumpOne = async () => {
261
+ const left = remaining();
262
+ if (left <= 0)
263
+ return null;
264
+ try {
265
+ const dumped = await client.dumpPane({
266
+ session,
267
+ paneId: pane.id,
268
+ timeoutMs: left,
269
+ });
270
+ throwIfAborted(signal);
271
+ return normalizeScreen(dumped.text);
272
+ }
273
+ catch (err) {
274
+ if (isCancelledError(err) || signal?.aborted) {
275
+ throw new ZellijError("cancelled", "operation cancelled");
276
+ }
277
+ return null;
278
+ }
279
+ };
280
+ const before = await dumpOne();
281
+ throwIfAborted(signal);
282
+ let after = null;
283
+ // This pane's interval starts when its first screen is available. Queue
284
+ // time and a slow first read cannot replace time between observations.
285
+ // Without room for the whole interval, keep it unknown rather than idle.
286
+ if (before !== null && remaining() > sampleMs) {
287
+ await clock.sleep(sampleMs);
288
+ throwIfAborted(signal);
289
+ after = await dumpOne();
290
+ }
291
+ samples.set(pane.id, { before, after });
292
+ });
293
+ }
294
+ throwIfAborted(signal);
163
295
  const peers = [];
296
+ let partial = false;
164
297
  for (const pane of targets) {
165
- const first = before.get(pane.id) ?? "";
166
- const after = pane.exited ? first : (afterScreens.get(pane.id) ?? "");
298
+ if (pane.exited) {
299
+ peers.push(peerEntry(pane, "exited", "", verbose));
300
+ continue;
301
+ }
302
+ const pair = samples.get(pane.id);
303
+ const first = pair?.before ?? null;
304
+ const after = pair?.after ?? null;
305
+ if (first == null || after == null) {
306
+ partial = true;
307
+ peers.push(peerEntry(pane, "unknown", after ?? first ?? "", verbose));
308
+ continue;
309
+ }
167
310
  const state = classify({
168
- exited: pane.exited,
311
+ exited: false,
169
312
  before: first,
170
313
  after,
171
314
  profile: resolveHarness(pane),
172
315
  });
173
- const entry = {
174
- id: pane.id,
175
- title: pane.title,
176
- state,
177
- lastLine: lastLine(after).slice(0, 160),
178
- };
179
- if (isTrue(args.verbose)) {
180
- entry.command = pane.command ?? null;
181
- entry.cwd = pane.cwd ?? null;
182
- entry.tab = pane.tabName ?? null;
183
- }
184
- peers.push(entry);
316
+ peers.push(peerEntry(pane, state, after, verbose));
185
317
  }
186
318
  peers.sort((a, b) => String(a.id).localeCompare(String(b.id)));
187
319
  const free = peers.filter((p) => p.state === "idle").map((p) => p.id);
188
- return {
189
- ok: true,
190
- data: { session, source, sampled: true, sampleMs, peers, free },
320
+ const data = {
321
+ session,
322
+ source,
323
+ sampled: true,
324
+ sampleMs,
325
+ peers,
326
+ tabs: statusTabs(peers),
327
+ free,
191
328
  };
329
+ if (partial || remaining() <= 0)
330
+ data.partial = true;
331
+ return { ok: true, data };
192
332
  }
@@ -1,4 +1,5 @@
1
- export type OpsResult = {
1
+ import type { RoutingContext } from "./routing.js";
2
+ export type OpsResult = ({
2
3
  ok: true;
3
4
  data: unknown;
4
5
  } | {
@@ -6,7 +7,10 @@ export type OpsResult = {
6
7
  error: {
7
8
  code: string;
8
9
  message: string;
10
+ details?: Record<string, unknown>;
9
11
  };
12
+ }) & {
13
+ context?: RoutingContext;
10
14
  };
11
15
  import type { GitClient } from "../git.js";
12
16
  import type { Policy } from "../policy.js";
@@ -13,6 +13,7 @@ export declare function paneViewSlim(p: ZellijPane): {
13
13
  title: string;
14
14
  command: string | null;
15
15
  tab: string | null;
16
+ tabId: number | null;
16
17
  };
17
18
  /**
18
19
  * The event-bus view. Zellij's pane manifest carries no command, so the key is
@@ -22,6 +23,7 @@ export declare function paneViewBus(p: ZellijPane): {
22
23
  id: string;
23
24
  title: string;
24
25
  tab: string | null;
26
+ tabId: number | null;
25
27
  };
26
28
  export declare function paneViewFull(p: ZellijPane): {
27
29
  cwd: string | null;
@@ -32,6 +34,7 @@ export declare function paneViewFull(p: ZellijPane): {
32
34
  title: string;
33
35
  command: string | null;
34
36
  tab: string | null;
37
+ tabId: number | null;
35
38
  };
36
39
  /** Truncate dump text; default keeps the tail (recent output). */
37
40
  export declare function truncateDumpText(text: string, maxChars: number, keep?: "tail" | "head"): {
package/dist/ops/util.js CHANGED
@@ -25,6 +25,7 @@ export function paneViewSlim(p) {
25
25
  title: p.title,
26
26
  command: p.command ?? null,
27
27
  tab: p.tabName ?? null,
28
+ tabId: p.tabId ?? null,
28
29
  };
29
30
  }
30
31
  /**
@@ -32,7 +33,7 @@ export function paneViewSlim(p) {
32
33
  * left out rather than reported as null — absent means unknown, not none.
33
34
  */
34
35
  export function paneViewBus(p) {
35
- return { id: p.id, title: p.title, tab: p.tabName ?? null };
36
+ return { id: p.id, title: p.title, tab: p.tabName ?? null, tabId: p.tabId ?? null };
36
37
  }
37
38
  export function paneViewFull(p) {
38
39
  return {
package/dist/schema.d.ts CHANGED
@@ -14,6 +14,8 @@ export type ParamSpec = {
14
14
  flags: string[];
15
15
  /** Repeatable flags collect into an array (`--key a --key b`). */
16
16
  repeat?: boolean;
17
+ /** Local CLI preprocessing, excluded from the MCP protocol. */
18
+ cliOnly?: boolean;
17
19
  values?: readonly string[];
18
20
  description: string;
19
21
  };
package/dist/schema.js CHANGED
@@ -63,7 +63,25 @@ export const PARAMS = [
63
63
  name: "all",
64
64
  type: "boolean",
65
65
  flags: ["--all", "-a"],
66
- description: "broadcast: every terminal pane in the session",
66
+ description: "broadcast: every terminal pane in the session; sessions: include EXITED resurrectable sessions",
67
+ },
68
+ {
69
+ name: "live",
70
+ type: "boolean",
71
+ flags: ["--live", "--active"],
72
+ description: "sessions: only live (non-EXITED) sessions — this is the default; use --all to include EXITED",
73
+ },
74
+ {
75
+ name: "local",
76
+ type: "boolean",
77
+ flags: ["--local"],
78
+ description: "route this call to the local machine only — clears ZSWARM_SSH, ZSWARM_SERVE, and remote ZSWARM_TMP for the invocation",
79
+ },
80
+ {
81
+ name: "ssh",
82
+ type: "string",
83
+ flags: ["--ssh"],
84
+ description: "route this call over SSH to user@host (or an alias) for the invocation; clears ZSWARM_SERVE; put flags in ZSWARM_SSH_OPTS",
67
85
  },
68
86
  {
69
87
  name: "group",
@@ -143,6 +161,13 @@ export const PARAMS = [
143
161
  flags: ["--body", "-b", "--text"],
144
162
  description: "send: message body",
145
163
  },
164
+ {
165
+ name: "bodyFile",
166
+ type: "string",
167
+ flags: ["--body-file"],
168
+ cliOnly: true,
169
+ description: "send: read UTF-8 on the caller from PATH, or - for stdin; cannot combine with --body/--text",
170
+ },
146
171
  {
147
172
  name: "text",
148
173
  type: "string",
@@ -220,7 +245,7 @@ export const PARAMS = [
220
245
  name: "timeoutMs",
221
246
  type: "number",
222
247
  flags: ["--timeout-ms"],
223
- description: "wait: give up after this long (default 60000)",
248
+ description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000), including setup and observation",
224
249
  },
225
250
  {
226
251
  name: "keys",
@@ -302,6 +327,12 @@ export const PARAMS = [
302
327
  values: ["auto", "double-enter", "none"],
303
328
  description: "send/broadcast: auto verifies the paste actually submitted and presses Enter again if not (default)",
304
329
  },
330
+ {
331
+ name: "observeMs",
332
+ type: "number",
333
+ flags: ["--observe-ms"],
334
+ description: "spawn: observe creation/alias for up to 3000ms; pane lookup: retry absence for 1000ms; 0 disables retries",
335
+ },
305
336
  {
306
337
  name: "settleMs",
307
338
  type: "number",
@@ -312,7 +343,7 @@ export const PARAMS = [
312
343
  name: "expect",
313
344
  type: "string",
314
345
  flags: ["--expect"],
315
- description: "text the target pane's screen must contain before zswarm will write to it",
346
+ description: "send/keys/interrupt: case-insensitive substring required on the current screen immediately before input",
316
347
  },
317
348
  {
318
349
  name: "message",
@@ -423,8 +454,10 @@ export function mcpInputSchema() {
423
454
  description: OP_NAMES.join(" | "),
424
455
  },
425
456
  };
426
- for (const param of PARAMS)
427
- properties[param.name] = propertyFor(param);
457
+ for (const param of PARAMS) {
458
+ if (!param.cliOnly)
459
+ properties[param.name] = propertyFor(param);
460
+ }
428
461
  return {
429
462
  type: "object",
430
463
  additionalProperties: false,
@@ -499,6 +532,9 @@ export function parseCliArgv(argv) {
499
532
  out[param.name] = true;
500
533
  continue;
501
534
  }
535
+ if ((param.name === "body" || param.name === "bodyFile") && Object.hasOwn(out, param.name)) {
536
+ throw new ZellijError("usage", "provide exactly one body source");
537
+ }
502
538
  const value = rest[++i];
503
539
  if (value === undefined) {
504
540
  throw new ZellijError("usage", `${token} needs a value`);
@@ -519,7 +555,7 @@ export function parseCliArgv(argv) {
519
555
  out.to = token;
520
556
  continue;
521
557
  }
522
- if (!out.body && op === "send") {
558
+ if (!Object.hasOwn(out, "body") && op === "send") {
523
559
  out.body = token;
524
560
  continue;
525
561
  }
@@ -527,5 +563,8 @@ export function parseCliArgv(argv) {
527
563
  }
528
564
  for (const [name, values] of repeated)
529
565
  out[name] = values;
566
+ if (out.bodyFile !== undefined && (op !== "send" || out.body !== undefined || out.text !== undefined)) {
567
+ throw new ZellijError("usage", "--body-file requires send and cannot be combined with another body source");
568
+ }
530
569
  return out;
531
570
  }
@@ -1,6 +1,7 @@
1
1
  export type PaneDirection = "right" | "left" | "up" | "down";
2
2
  export type NewPaneInput = {
3
3
  session: string;
4
+ timeoutMs?: number;
4
5
  command?: string[];
5
6
  cwd?: string | null;
6
7
  name?: string | null;
@@ -13,6 +14,7 @@ export type NewPaneInput = {
13
14
  };
14
15
  export type NewTabInput = {
15
16
  session: string;
17
+ timeoutMs?: number;
16
18
  command?: string[];
17
19
  cwd?: string | null;
18
20
  name?: string | null;
@@ -6,6 +6,40 @@ export declare const DEFAULT_TIMEOUT_MS = 15000;
6
6
  export { NOT_FOUND_EXIT };
7
7
  /** Expand a leading `~/` or `~\` using USERPROFILE/HOME. */
8
8
  export declare function expandHomePath(input: string, env?: NodeJS.ProcessEnv): string;
9
+ /**
10
+ * True when a path (or basename) is the zswarm CLI rather than Zellij.
11
+ * Setting ZSWARM_BIN to zswarm makes `list-sessions --short` fail with
12
+ * "unknown arg: --short" because zswarm re-parses argv as its own CLI.
13
+ */
14
+ export declare function looksLikeZswarmBinary(path: string): boolean;
15
+ export declare function assertZellijBinaryPath(path: string): void;
16
+ /**
17
+ * ZSWARM_SSH is a destination, not a full ssh argv.
18
+ * Accept `user@host` or an SSH config alias; put flags in ZSWARM_SSH_OPTS.
19
+ */
20
+ export declare function validateSshDestination(raw: string): string;
21
+ export declare function validateSshMode(raw: string): "ssh" | "interactive";
22
+ /** True when `--version` output is positively Zellij. */
23
+ export declare function isZellijVersionOutput(stdout: string, stderr?: string): boolean;
24
+ /**
25
+ * Cache key covering the resolved binary and SSH routing that can change the
26
+ * actual remote executable (opts, mode, remote bin).
27
+ */
28
+ export declare function identityCacheKey(zellijPath: string, ssh?: {
29
+ host: string;
30
+ options: string[];
31
+ mode?: string;
32
+ remoteBin?: string;
33
+ } | null): string;
34
+ /**
35
+ * Confirm the resolved binary is Zellij. Only verified identities are cached.
36
+ * Transport timeouts leave the cache empty so a later call can retry.
37
+ * Returns false when verification is unresolved; only true is cached.
38
+ */
39
+ export declare function ensureZellijIdentity(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
40
+ export declare function ensureZellijCapabilities(exec: ExecFn, zellijPath: string, timeoutMs?: number, cacheKey?: string): Promise<boolean>;
41
+ /** Test helper: drop cached identity/capability probes. */
42
+ export declare function resetZellijIdentityCache(): void;
9
43
  export declare function resolveZellijBinary(env?: NodeJS.ProcessEnv): string;
10
44
  export declare function sanitizeZellijEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
11
45
  /**