cyber-mux 0.4.0 → 0.5.0

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.
@@ -50,29 +50,60 @@ function envFallback(env, command) {
50
50
  };
51
51
  }
52
52
  //#endregion
53
- //#region src/ratio.ts
53
+ //#region src/floating.ts
54
54
  /**
55
- * The seam's own precondition on `MuxOpenOptions.ratio`: a fraction STRICTLY between 0 and 1.
55
+ * The floating-pane refusal — the `'pane:float'` placement's answer on a backend that has no
56
+ * floating-pane concept (wezterm, herdr).
56
57
  *
57
- * `ratio` is the fraction kept by the ORIGINAL pane. Outside `0 < ratio < 1` there is no split it can
58
- * name: `1 - ratio` goes negative above 1 (tmux `-l -50%` / wezterm `--percent -50`), and 0 or 1 hands
59
- * one side the whole region and the other nothing — a mistake, never an intent worth honoring. Left
60
- * unrendered these produce a silently broken split, not an error, which is the exact silent-wrong
61
- * output this seam's loud-over-quiet preference exists to refuse.
58
+ * Its own module rather than a member of `mux.ts` for the reason every other seam type is not a
59
+ * class: `mux.ts` is the CONTRACT and carries no runtime value, so putting the one class the
60
+ * contract's refusal needs there would make every consumer of the types import a value too. It is
61
+ * the core-surface parallel of `CaptureUnsupportedError` (`template-capture.ts`) and
62
+ * `AgentLifecycleUnsupportedError` (`agent.ts`), and it rides the `.` barrel rather than a subpath
63
+ * because the verb it refuses — `open` — is on the surface everybody gets.
64
+ */
65
+ /**
66
+ * A floating pane asked of a backend that cannot open one (`open` with `at: 'pane:float'` on wezterm
67
+ * or herdr). A refusal, never a substitution: the nearest thing those backends could open is a tiled
68
+ * split, which takes a share of the region and resizes its other panes — exactly the property a float
69
+ * exists to avoid — so a caller would get back a pane whose id satisfies them and whose behavior does
70
+ * not. There is no truthful degrade, so there is no degrade.
62
71
  *
63
- * Enforced HERE, at the seam, rather than left to each caller, because the invariant is a universal
64
- * property of what a ratio IS — true on every backend — not a per-caller policy. (The DEGRADE policy —
65
- * what a caller does when a backend cannot size a split at all — genuinely stays the caller's, unchanged;
66
- * range validity and degrade policy are different questions.) A caller cannot reach an adapter with an
67
- * out-of-range ratio and have it silently rendered; `template`'s schema still refuses one earlier, per
68
- * node, with a path-qualified message, so the two layers do different jobs and the seam is the backstop.
72
+ * PORTABLE and exit-code-free by design, the exact mirror of `AgentLifecycleUnsupportedError`. The
73
+ * DECISION to refuse is the library's, made inside each adapter's `open` — the one place that sees
74
+ * both the backend and the requested placement. How the refusal SURFACES (the exit code, the fix
75
+ * hint, the exact sentence) is the CLI's, which catches this and re-raises its own
76
+ * `backend-unsupported` error. `backend` names the backend so a caller composes the message without
77
+ * re-deriving it; the terse `message` is a factual log line.
78
+ */
79
+ var FloatingPanesUnsupportedError = class extends Error {
80
+ backend;
81
+ constructor(backend) {
82
+ super(`${backend} cannot open a floating pane`);
83
+ this.backend = backend;
84
+ this.name = "FloatingPanesUnsupportedError";
85
+ }
86
+ };
87
+ /**
88
+ * Refuse a `'pane:float'` open on the backend named — the single spelling of the refusal, called by
89
+ * every adapter that lacks the capability so the two cannot drift into two different messages.
69
90
  *
70
- * The guard lives WITH the rendering: it is called by each backend's size render helper, so a backend
71
- * that cannot size a split (zellij) renders no ratio and so never reaches this guard — a dropped value
72
- * is never checked, valid or not, which is the same as the even-default degrade its callers already take.
91
+ * Takes the NAME rather than the adapter: it is called from inside `open`, where the adapter object is
92
+ * still being constructed on some backends, and the name is the only thing the error carries anyway.
73
93
  */
74
- function assertRatioInRange(ratio) {
75
- if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) throw new Error(`ratio must be strictly between 0 and 1 — got ${ratio}`);
94
+ function refuseFloatingPane(backend) {
95
+ throw new FloatingPanesUnsupportedError(backend);
96
+ }
97
+ /**
98
+ * Whether `adapter` can open a floating pane — the declaration read, so a caller asking the question
99
+ * before opening spells it once rather than reaching into an optional member that may be `undefined`.
100
+ *
101
+ * The pre-flight check, not the enforcement: `open` re-checks as its own contract (the same
102
+ * belt-and-braces `agent wait` runs against `agentLifecycle`), so a caller that skips this is refused
103
+ * just as loudly — one exec later.
104
+ */
105
+ function canFloatPanes(adapter) {
106
+ return adapter.canFloatPanes === true;
76
107
  }
77
108
  //#endregion
78
109
  //#region src/wait-output.ts
@@ -156,7 +187,7 @@ async function pollForOutput(adapter, exec, target, opts) {
156
187
  const deadline = now() + opts.timeoutMs;
157
188
  let output = "";
158
189
  for (;;) {
159
- output = adapter.read(exec, target, readOpts);
190
+ output = adapter.read(exec, target, readOpts).text;
160
191
  const result = matchWaitPattern(output, opts);
161
192
  if (result.matched) return result;
162
193
  if (now() >= deadline) break;
@@ -174,6 +205,357 @@ function assertPaneLive(adapter, exec, target) {
174
205
  if (!adapter.paneExists(exec, target)) throw new Error(`wait failed: pane ${target.id} no longer exists — the pane is gone, not quiet.`);
175
206
  }
176
207
  //#endregion
208
+ //#region src/mux.cmux.ts
209
+ /**
210
+ * cmux backend — detected via `$CMUX_WORKSPACE_ID`. Drives cmux's CLI through `cmux <verb> …`
211
+ * (https://cmux.com/docs/api), the same synchronous-CLI shape tmux, herdr, wezterm, and zellij
212
+ * already give `Exec`.
213
+ *
214
+ * cmux is a Ghostty-based macOS terminal with vertical tabs and notifications for AI coding agents.
215
+ * Its hierarchy is Window → Workspace → Pane → Surface. A **Surface** is the terminal unit (a tab
216
+ * within a pane), which maps to cyber-mux's `LivePane`. The env variable `$CMUX_SURFACE_ID` carries
217
+ * the caller's surface identity — analogous to `$TMUX_PANE` or `$WEZTERM_PANE`.
218
+ *
219
+ * Probed from the cmux docs and CLI reference only — cmux is not installed in this sandbox (it is
220
+ * macOS-GUI-only), so nothing here carries the "verified against a live binary" claim
221
+ * `mux.tmux.ts`/`mux.herdr.ts` make; it makes the same honest disclaimer `mux.wezterm.ts` and
222
+ * `mux.zellij.ts` do.
223
+ *
224
+ * Real capability shape that fell out of the probe:
225
+ *
226
+ * - **Surface is the terminal unit, not Pane.** cmux's "pane" holds multiple "surfaces" (tabs). The
227
+ * terminal a command runs in is a surface, so `CMUX_SURFACE_ID` is the self-identity env var and
228
+ * `LivePane.id` carries a surface id. `--at tab` maps to `cmux new-surface` (a new tab in the
229
+ * current pane); `--at pane:*` maps to `cmux new-pane` (a split, which creates a pane with one
230
+ * surface).
231
+ * - **Workspace is a real tier.** `cmux new-workspace` creates a genuinely separate workspace,
232
+ * reported as `OpenedPane.workspace`.
233
+ * - **No `--env` on any route.** Like wezterm and zellij, env is native at no tier, so every open
234
+ * rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
235
+ * warning when there is no command to ride).
236
+ * - **Splits can be sized** — `cmux new-pane --direction right --size 0.3` sizes the NEW pane, so
237
+ * `ratio` (fraction kept by the ORIGINAL) is inverted to `1 - ratio`. `canSizeSplits` is true.
238
+ * - **`new-pane` has no split-TARGET flag.** It splits the focused pane (or the biggest space); the
239
+ * `--workspace` flag specifies which workspace, but not which pane within it. So `from` — which
240
+ * pane a `pane:*` split lands beside — is honored by FOCUSING that surface first, the sole way to
241
+ * choose the split target. That is a real focus move, and the honest cost of getting the RIGHT
242
+ * pane split.
243
+ * - **No pane geometry adapter.** `cmux list-panes --json` does not report position, so `regions`
244
+ * (`describeRegion`/`describeWorkspace`) is not implementable. `template save` refuses on cmux by
245
+ * naming the backend, the same optional-absence it handles for wezterm.
246
+ * - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux, wezterm, and
247
+ * zellij — this backend never binds a worktree to a workspace; callers fall back to plain git plus
248
+ * `open()`.
249
+ * - **Naming surfaces.** cmux surfaces can be labeled — verified against the skill docs: a label can
250
+ * be set after creation. No `--label` flag on `new-surface` / `new-pane`, so naming is post-birth.
251
+ */
252
+ function createCmuxAdapter(deps) {
253
+ const adapter = {
254
+ name: "cmux",
255
+ canSizeSplits: true,
256
+ open(exec, opts) {
257
+ const at = opts.at ?? "tab";
258
+ if (at === "workspace") {
259
+ const args = ["--json", "new-workspace"];
260
+ if (opts.cwd) args.push("--cwd", opts.cwd);
261
+ const out = exec("cmux", args);
262
+ if (!out) throw new Error(withReason(exec, "cmux new-workspace failed"));
263
+ const parsed = parseCmuxOutput(out);
264
+ if (!parsed.workspace_ref) throw new Error("cmux new-workspace did not report the workspace ref");
265
+ const surfaceId = parsed.surface_ref;
266
+ if (!surfaceId) throw new Error("cmux new-workspace did not report the initial surface ref");
267
+ const opened = openedSurface(surfaceId, parsed.pane_ref, parsed.workspace_ref);
268
+ if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
269
+ runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
270
+ return opened;
271
+ }
272
+ if (at === "tab") {
273
+ const args = ["--json", "new-surface"];
274
+ if (opts.within) args.push("--pane", opts.within);
275
+ if (opts.cwd) args.push("--cwd", opts.cwd);
276
+ const out = exec("cmux", args);
277
+ if (!out) throw new Error(withReason(exec, "cmux new-surface failed"));
278
+ const parsed = parseCmuxOutput(out);
279
+ const surfaceId = parsed.surface_ref;
280
+ if (!surfaceId) throw new Error("cmux new-surface did not report the surface ref");
281
+ const opened = openedSurface(surfaceId, parsed.pane_ref, deps.workspace);
282
+ if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
283
+ runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
284
+ return opened;
285
+ }
286
+ if (at === "pane:float") refuseFloatingPane(adapter.name);
287
+ if (opts.from) adapter.focus(exec, opts.from);
288
+ const args = [
289
+ "--json",
290
+ "new-pane",
291
+ "--direction",
292
+ at === "pane:down" ? "down" : "right"
293
+ ];
294
+ if (opts.cwd) args.push("--cwd", opts.cwd);
295
+ if (opts.ratio != null) args.push("--size", String(1 - opts.ratio));
296
+ const out = exec("cmux", args);
297
+ if (!out) throw new Error(withReason(exec, "cmux new-pane failed"));
298
+ const parsed = parseCmuxOutput(out);
299
+ const surfaceId = parsed.surface_ref;
300
+ if (!surfaceId) throw new Error("cmux new-pane did not report the surface ref");
301
+ const opened = openedSurface(surfaceId, parsed.pane_ref, deps.workspace);
302
+ if (opts.label) adapter.rename(exec, opened, "pane", opts.label);
303
+ runLaunch$3(adapter, exec, opened, opts.env, opts.launch);
304
+ return opened;
305
+ },
306
+ rename(exec, target, tier, name) {
307
+ if (tier === "tab") {
308
+ exec("cmux", [
309
+ "rename-surface",
310
+ "--surface",
311
+ target.id,
312
+ "--title",
313
+ name
314
+ ]);
315
+ return;
316
+ }
317
+ const paneRef = surfaceToPane(exec, target.id);
318
+ if (paneRef) exec("cmux", [
319
+ "rename-pane",
320
+ "--pane",
321
+ paneRef,
322
+ "--title",
323
+ name
324
+ ]);
325
+ },
326
+ group() {},
327
+ sendText(exec, target, text) {
328
+ exec("cmux", [
329
+ "send",
330
+ "--surface",
331
+ target.id,
332
+ text
333
+ ]);
334
+ },
335
+ sendKeys(exec, target, keys) {
336
+ for (const key of keys) exec("cmux", [
337
+ "send-key",
338
+ "--surface",
339
+ target.id,
340
+ toCmuxKey(key)
341
+ ]);
342
+ },
343
+ submit(exec, target, text) {
344
+ if (!text) {
345
+ adapter.sendKeys(exec, target, ["Enter"]);
346
+ return;
347
+ }
348
+ adapter.sendText(exec, target, text);
349
+ adapter.sendKeys(exec, target, ["Enter"]);
350
+ },
351
+ read(exec, target, opts) {
352
+ const args = [
353
+ "read-screen",
354
+ "--surface",
355
+ target.id
356
+ ];
357
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
358
+ return { text: exec("cmux", args) ?? "" };
359
+ },
360
+ waitForOutput(exec, target, opts) {
361
+ return pollForOutput(adapter, exec, target, opts);
362
+ },
363
+ focus(exec, target) {
364
+ exec("cmux", [
365
+ "focus-panel",
366
+ "--panel",
367
+ target.id
368
+ ]);
369
+ },
370
+ teardown(exec, target) {
371
+ exec("cmux", [
372
+ "close-surface",
373
+ "--surface",
374
+ target.id
375
+ ]);
376
+ },
377
+ paneExists(exec, target) {
378
+ return listCmuxSurfaces(exec).some((s) => s.id === target.id);
379
+ },
380
+ isPaneFocused(exec, target) {
381
+ const found = listCmuxSurfaces(exec).find((s) => s.id === target.id);
382
+ if (!found) return void 0;
383
+ return found.is_focused === true;
384
+ },
385
+ listPanes(exec) {
386
+ return listCmuxSurfaces(exec).map((s) => {
387
+ const pane = {
388
+ id: s.id,
389
+ mux: "cmux",
390
+ floating: false
391
+ };
392
+ if (s.cwd) pane.cwd = s.cwd;
393
+ if (s.title) pane.label = s.title;
394
+ return pane;
395
+ });
396
+ }
397
+ };
398
+ return adapter;
399
+ }
400
+ function parseCmuxOutput(out) {
401
+ try {
402
+ return JSON.parse(out);
403
+ } catch {
404
+ return {};
405
+ }
406
+ }
407
+ function listCmuxSurfaces(exec) {
408
+ const out = exec("cmux", ["list-panes", "--json"]);
409
+ if (!out) return [];
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(out);
413
+ } catch {
414
+ return [];
415
+ }
416
+ if (!Array.isArray(parsed)) return [];
417
+ const surfaces = [];
418
+ for (const pane of parsed) if (pane && Array.isArray(pane.surfaces)) {
419
+ for (const s of pane.surfaces) if (s?.surface_ref) surfaces.push({
420
+ id: s.surface_ref,
421
+ title: s.title,
422
+ cwd: s.cwd,
423
+ is_focused: s.is_focused
424
+ });
425
+ }
426
+ return surfaces;
427
+ }
428
+ function openedSurface(surfaceId, paneRef, workspace) {
429
+ const opened = {
430
+ id: surfaceId,
431
+ tab: paneRef ?? surfaceId
432
+ };
433
+ if (workspace) opened.workspace = workspace;
434
+ return opened;
435
+ }
436
+ function surfaceToPane(exec, surfaceId) {
437
+ const out = exec("cmux", ["list-panes", "--json"]);
438
+ if (!out) return void 0;
439
+ let parsed;
440
+ try {
441
+ parsed = JSON.parse(out);
442
+ } catch {
443
+ return;
444
+ }
445
+ if (!Array.isArray(parsed)) return void 0;
446
+ for (const pane of parsed) if (pane?.pane_ref && Array.isArray(pane.surfaces)) {
447
+ for (const s of pane.surfaces) if (s && s.surface_ref === surfaceId) return pane.pane_ref;
448
+ }
449
+ }
450
+ function runLaunch$3(adapter, exec, target, env, launch) {
451
+ const fallback = envFallback(env, launch);
452
+ if (fallback.kind === "dropped") {
453
+ process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this cmux surface — cmux has no --env flag on new-pane/new-surface/new-workspace
454
+ `);
455
+ return;
456
+ }
457
+ if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
458
+ }
459
+ /**
460
+ * The core key vocabulary's cmux spelling. cmux uses lowercase key names: enter, tab, escape, etc.
461
+ */
462
+ const CMUX_KEY_RENAMES = {
463
+ Enter: "enter",
464
+ Tab: "tab",
465
+ Escape: "escape",
466
+ Backspace: "backspace",
467
+ Space: "space",
468
+ Up: "up",
469
+ Down: "down",
470
+ Left: "left",
471
+ Right: "right",
472
+ "C-c": "ctrl+c"
473
+ };
474
+ function toCmuxKey(key) {
475
+ return CMUX_KEY_RENAMES[key] ?? key.toLowerCase();
476
+ }
477
+ //#endregion
478
+ //#region src/ratio.ts
479
+ /**
480
+ * The seam's own precondition on `MuxOpenOptions.ratio`: a fraction STRICTLY between 0 and 1.
481
+ *
482
+ * `ratio` is the fraction kept by the ORIGINAL pane. Outside `0 < ratio < 1` there is no split it can
483
+ * name: `1 - ratio` goes negative above 1 (tmux `-l -50%` / wezterm `--percent -50`), and 0 or 1 hands
484
+ * one side the whole region and the other nothing — a mistake, never an intent worth honoring. Left
485
+ * unrendered these produce a silently broken split, not an error, which is the exact silent-wrong
486
+ * output this seam's loud-over-quiet preference exists to refuse.
487
+ *
488
+ * Enforced HERE, at the seam, rather than left to each caller, because the invariant is a universal
489
+ * property of what a ratio IS — true on every backend — not a per-caller policy. (The DEGRADE policy —
490
+ * what a caller does when a backend cannot size a split at all — genuinely stays the caller's, unchanged;
491
+ * range validity and degrade policy are different questions.) A caller cannot reach an adapter with an
492
+ * out-of-range ratio and have it silently rendered; `template`'s schema still refuses one earlier, per
493
+ * node, with a path-qualified message, so the two layers do different jobs and the seam is the backstop.
494
+ *
495
+ * The guard lives WITH the rendering: it is called by each backend's size render helper, so a backend
496
+ * that cannot size a split (zellij) renders no ratio and so never reaches this guard — a dropped value
497
+ * is never checked, valid or not, which is the same as the even-default degrade its callers already take.
498
+ */
499
+ function assertRatioInRange(ratio) {
500
+ if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) throw new Error(`ratio must be strictly between 0 and 1 — got ${ratio}`);
501
+ }
502
+ //#endregion
503
+ //#region src/read-window.ts
504
+ /**
505
+ * The READ WINDOW: how much of a pane a capture asks for, and whether rows sat above what came back.
506
+ *
507
+ * Both halves live here because they are one question. A capture is bounded — by the caller's `lines`,
508
+ * or by the backend's own default (the viewport on all four) — and "was anything dropped" is a
509
+ * property of that bound. Unbind the window (`lines: 'all'`) and the answer is `false` by
510
+ * construction, with no probe to spend.
511
+ *
512
+ * The truncation rule itself is shared by every adapter so the four backends answer
513
+ * `MuxReadResult.truncated` by one definition rather than four that can drift — the same reason
514
+ * `pollForOutput` owns one poll cadence for the three polling backends.
515
+ *
516
+ * Pure, and deliberately so: the tricky half of the answer is a row count, testable with no
517
+ * multiplexer at all (`template-capture`'s geometry derivation is the precedent). Each adapter owns
518
+ * only the one thing that genuinely differs — how its backend spells "one row deeper".
519
+ */
520
+ /**
521
+ * How many terminal ROWS a capture carries.
522
+ *
523
+ * A trailing newline is a terminator, not an empty row: `capture-pane` and `dump-screen` both end
524
+ * their output with one, so counting it would make every capture look one row longer than the screen
525
+ * and — worse — would compare unequal against a probe that happened not to end with one. An empty
526
+ * capture is zero rows, not one.
527
+ */
528
+ function capturedRows(text) {
529
+ if (text === "") return 0;
530
+ return (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n").length;
531
+ }
532
+ /**
533
+ * Whether `capture` omitted older rows, judged against `deeper` — the SAME read taken one row further
534
+ * back.
535
+ *
536
+ * More rows in the deeper read means there was output above the captured window that the caller did
537
+ * not receive. An equal (or smaller) count means the deeper read had nothing more to give: the
538
+ * backend clamped at the top of what it holds, so the caller has everything there is.
539
+ *
540
+ * Row counts, not text equality, because the two reads are not required to render the shared rows
541
+ * identically — herdr's deeper probe reads a different `--source`, and a backend may re-wrap. What
542
+ * both reads DO agree on is how many rows they returned, and that is the whole question.
543
+ */
544
+ function isReadTruncated(capture, deeper) {
545
+ return capturedRows(deeper) > capturedRows(capture);
546
+ }
547
+ /**
548
+ * The row count that stands in for "the whole scrollback" on a backend whose read takes a NUMBER and
549
+ * has no all-history token of its own (WezTerm's `--start-line`, herdr's `--lines`) — tmux (`-S -`)
550
+ * and Zellij (`--full`) say it exactly and never reach for this.
551
+ *
552
+ * A million rows is past any real pane's history (tmux's own `history-limit` defaults to 2000) and
553
+ * both backends CLAMP an over-deep window to what they hold rather than failing, so this reads as
554
+ * "everything" without pretending to be a precise number. It stays under a u32, which is what herdr's
555
+ * CLI parses `--lines` as.
556
+ */
557
+ const FULL_SCROLLBACK_LINES = 1e6;
558
+ //#endregion
177
559
  //#region src/mux.herdr.ts
178
560
  /**
179
561
  * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
@@ -184,6 +566,13 @@ function assertPaneLive(adapter, exec, target) {
184
566
  *
185
567
  * The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
186
568
  * returns a JSON `pane_info` envelope whose id is extracted in `parsePaneId`.
569
+ *
570
+ * **Verified against 0.8.0** (protocol 19), re-probed against a live server. Everything this adapter
571
+ * drives held: the split/read/run/send-keys lifecycle, `pane wait-output`'s success and error
572
+ * envelopes, `pane list`/`get`/`layout`, `workspace create`/`tab create`, and the `env` and worktree
573
+ * parameter sets below. The per-claim markers that follow name the version each was LAST established
574
+ * against — a claim still reading 0.7.4/0.7.5 is one 0.8.0 gave no occasion to re-measure (no attached
575
+ * client, or no live agent in the pane), not one that failed.
187
576
  */
188
577
  const herdrMuxAdapter = {
189
578
  name: "herdr",
@@ -218,7 +607,8 @@ const herdrMuxAdapter = {
218
607
  ]);
219
608
  if (!out) throw new Error(withReason(exec, "herdr tab create failed"));
220
609
  opened = parseRootPaneId(out, "herdr tab create");
221
- } else {
610
+ } else if (at === "pane:float") refuseFloatingPane(herdrMuxAdapter.name);
611
+ else {
222
612
  const direction = at === "pane:down" ? "down" : "right";
223
613
  const from = opts.from ? [opts.from.id] : ["--current"];
224
614
  const size = opts.ratio != null ? ["--ratio", toHerdrRatio(opts.ratio)] : [];
@@ -283,23 +673,38 @@ const herdrMuxAdapter = {
283
673
  text
284
674
  ]);
285
675
  },
676
+ /**
677
+ * 0.8.0 added a `truncated` boolean to the read result, and it is REQUIRED in the socket schema's
678
+ * `PaneReadResult` — but it is not reachable from here, and that is a fact about the CLI rather than
679
+ * a choice: `herdr pane read` prints the pane's bare TEXT, no envelope, and its only 0.8.0 additions
680
+ * are `--format`/`--ansi`/`--raw`, which select the text's escaping. There is no `--json`. So the
681
+ * flag rides the socket API this adapter deliberately does not speak, and the one CLI surface that
682
+ * does hand back the envelope is `pane wait-output` (`.result.read.truncated` — seen live on 0.8.0).
683
+ * Surfacing truncation therefore costs a return-shape change, not a flag; see issue #100, which owns
684
+ * it across all four backends. Noted here so the next reader does not re-derive the dead end.
685
+ */
286
686
  read(exec, target, opts) {
287
- const args = [
288
- "pane",
289
- "read",
290
- target.id,
291
- "--source",
292
- "visible"
293
- ];
294
- if (opts?.lines != null) args.push("--lines", String(opts.lines));
295
- return exec("herdr", args) ?? "";
687
+ if (opts?.lines === "all") {
688
+ const text = paneRead(exec, target, "recent", FULL_SCROLLBACK_LINES);
689
+ return opts.truncation ? {
690
+ text,
691
+ truncated: false
692
+ } : { text };
693
+ }
694
+ const text = paneRead(exec, target, "visible", opts?.lines);
695
+ if (!opts?.truncation) return { text };
696
+ return {
697
+ text,
698
+ truncated: isReadTruncated(text, paneRead(exec, target, "recent", capturedRows(text) + 1))
699
+ };
296
700
  },
297
701
  /**
298
- * The one backend with a NATIVE wait: `pane wait-output` blocks in herdr itself (0.7.5), so no poll
299
- * loop is run here and no snapshot is pulled across the CLI boundary on every tick.
702
+ * The one backend with a NATIVE wait: `pane wait-output` blocks in herdr itself (arrived in 0.7.5,
703
+ * still native in 0.8.0), so no poll loop is run here and no snapshot is pulled across the CLI
704
+ * boundary on every tick.
300
705
  *
301
706
  * `--source visible` is pinned rather than left to herdr's own default (`recent_unwrapped`, verified
302
- * against 0.7.5 — the help says `recent`). The seam's rule is that a wait searches exactly what
707
+ * against 0.7.5 — the help still says `recent` in 0.8.0). The seam's rule is that a wait searches exactly what
303
708
  * `read` returns, and `read` pins `visible` here; taking the default would make the same wait mean a
304
709
  * different snapshot on this backend than on every polling one.
305
710
  *
@@ -307,8 +712,8 @@ const herdrMuxAdapter = {
307
712
  * spells both the same way: exit 1 with an error envelope on stderr, so `Exec` yields `null` for
308
713
  * either. Two tiers answer it, in order:
309
714
  *
310
- * 1. **The envelope's `code`**, when the runner captured stderr into `lastError` (verified against
311
- * 0.7.5: `{"error":{"code":"timeout",…}}` vs `{"error":{"code":"pane_not_found",…}}`). Exact.
715
+ * 1. **The envelope's `code`**, when the runner captured stderr into `lastError` (re-verified against
716
+ * 0.8.0: `{"error":{"code":"timeout",…}}` vs `{"error":{"code":"pane_not_found",…}}`). Exact.
312
717
  * 2. **A live pane that actually consumed the deadline**, when it did not. `Exec.lastError` is
313
718
  * specified as a diagnostic and NEVER a control-flow signal — a runner that discards stderr must
314
719
  * still work — so the code cannot be the only answer. Liveness alone is not enough either, and the
@@ -344,7 +749,7 @@ const herdrMuxAdapter = {
344
749
  const readOpts = opts.lines != null ? { lines: opts.lines } : void 0;
345
750
  return {
346
751
  matched: false,
347
- output: herdrMuxAdapter.read(exec, target, readOpts)
752
+ output: herdrMuxAdapter.read(exec, target, readOpts).text
348
753
  };
349
754
  }
350
755
  return parseWaitOutput(out);
@@ -413,6 +818,7 @@ const herdrMuxAdapter = {
413
818
  return {
414
819
  id: p.pane_id,
415
820
  mux: "herdr",
821
+ floating: false,
416
822
  ...harness !== void 0 ? { harness } : {},
417
823
  ...agentStatus !== void 0 ? { agentStatus } : {},
418
824
  ...p.cwd !== void 0 ? { cwd: p.cwd } : {},
@@ -437,9 +843,9 @@ const herdrMuxAdapter = {
437
843
  * in a DIFFERENT workspace, so nothing has to be focused first and nothing moves while this runs.
438
844
  *
439
845
  * herdr's own native per-tab layout export would be the obvious road — it takes a `tab_id` — but
440
- * `layout` is NOT a CLI verb in 0.7.4; it is socket-API-only, and this adapter speaks the CLI by
441
- * design (so it composes with the synchronous `Exec` seam). The road is closed, hence the pane
442
- * indirection.
846
+ * `layout` is still NOT a CLI verb in 0.8.0 (its top-level help lists no such subcommand); it is
847
+ * socket-API-only, and this adapter speaks the CLI by design (so it composes with the synchronous
848
+ * `Exec` seam). The road is closed, hence the pane indirection.
443
849
  */
444
850
  describeWorkspace(exec, target) {
445
851
  const { workspaceId } = parsePaneRecord(exec("herdr", [
@@ -494,7 +900,9 @@ const herdrMuxAdapter = {
494
900
  } }
495
901
  };
496
902
  /**
497
- * The set of `agent_status` values herdr 0.7.5 reports — the runtime witness of the `AgentStatus`
903
+ * The set of `agent_status` values herdr reports — unchanged through 0.8.0, whose socket schema still
904
+ * declares `AgentStatus` as exactly this enum, and whose `agent wait --until` still lists exactly these
905
+ * five values. The runtime witness of the `AgentStatus`
498
906
  * type, so a string read off a herdr envelope can be NARROWED to it rather than cast. A value outside
499
907
  * this set is treated as absent (the feed said something this build does not model), never forced into
500
908
  * the type.
@@ -610,8 +1018,10 @@ function herdrPaneDetails(exec, workspace) {
610
1018
  *
611
1019
  * `worktree create`/`worktree open` are deliberately NOT in that list: their params are
612
1020
  * `[base, branch, cwd, focus, label, path, workspace_id]` and
613
- * `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and 0.7.4 rejects the flag with
1021
+ * `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and herdr rejects the flag with
614
1022
  * `unknown option: --env`. A caller needing env on that route uses the command-prefix fallback.
1023
+ * Re-verified against 0.8.0: both param sets are unchanged in protocol 19's schema, and a live
1024
+ * `worktree create --env` there still answers `unknown option: --env`.
615
1025
  */
616
1026
  function envFlags(env) {
617
1027
  return env ? Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]) : [];
@@ -821,8 +1231,8 @@ function parseRootPaneId(out, label) {
821
1231
  /**
822
1232
  * Every pane herdr emits carries its own `workspace_id` alongside its `pane_id`, on EVERY route —
823
1233
  * `workspace create` (which reports the workspace it just made), `tab create` (the workspace the tab
824
- * was created in), and `pane split` (the workspace the split landed in, i.e. the caller's). Verified
825
- * against herdr 0.7.4. That is why the workspace costs no extra call: it rides in on the same output
1234
+ * was created in), and `pane split` (the workspace the split landed in, i.e. the caller's). Re-verified
1235
+ * against herdr 0.8.0. That is why the workspace costs no extra call: it rides in on the same output
826
1236
  * the pane id is already read from, so probing for it separately would buy nothing and cost a round
827
1237
  * trip per open.
828
1238
  *
@@ -913,6 +1323,270 @@ function parseWorktreeBindings(out) {
913
1323
  }
914
1324
  return bindings;
915
1325
  }
1326
+ /**
1327
+ * One spelling of `pane read`, taken by `read` for the snapshot AND for its truncation probe — so the
1328
+ * two differ only in the source and depth they are meant to differ in. `lines` omitted takes herdr's
1329
+ * own default window for the source.
1330
+ */
1331
+ function paneRead(exec, target, source, lines) {
1332
+ const args = [
1333
+ "pane",
1334
+ "read",
1335
+ target.id,
1336
+ "--source",
1337
+ source
1338
+ ];
1339
+ if (lines != null) args.push("--lines", String(lines));
1340
+ return exec("herdr", args) ?? "";
1341
+ }
1342
+ //#endregion
1343
+ //#region src/mux.otty.ts
1344
+ /**
1345
+ * otty backend — detected via `$OTTY_PANE_ID`. Drives otty's CLI through `otty pane <verb> …`
1346
+ * (https://docs.otty.sh/reference/cli), the same synchronous-CLI shape the other backends use.
1347
+ *
1348
+ * otty is a native terminal-centric workspace app with integrated multiplexing (Windows > Tabs >
1349
+ * Splits > Panes). Its hierarchy maps onto cyber-mux's placement tiers as:
1350
+ * - **Workspace** → Window (a new window, spawned via `otty open --new-window`)
1351
+ * - **Tab** → Tab (a new tab via `otty tab new`)
1352
+ * - **Pane** → Pane (a split via `otty pane split`)
1353
+ *
1354
+ * The env variable `$OTTY_PANE_ID` carries the caller's pane identity — analogous to `$TMUX_PANE`
1355
+ * or `$WEZTERM_PANE`. `$OTTY_SOCKET` is the IPC socket path (detection hint only).
1356
+ *
1357
+ * Probed from the otty docs only — otty is a GUI-only app not installed in this sandbox, so
1358
+ * nothing here carries the "verified against a live binary" claim `mux.tmux.ts`/`mux.herdr.ts`
1359
+ * make; it makes the same honest disclaimer the other GUI-based adapters do.
1360
+ *
1361
+ * Real capability shape from the docs:
1362
+ *
1363
+ * - **Pane is the terminal unit.** `OTTY_PANE_ID` is the self-identity env var and `LivePane.id`
1364
+ * carries a pane id. `--at tab` maps to `otty tab new`; `--at pane:*` maps to `otty pane split`.
1365
+ * - **Window is the workspace tier.** `otty open --new-window` creates a genuinely separate window,
1366
+ * reported as `OpenedPane.workspace`.
1367
+ * - **No `--env` on any route.** Like wezterm/zellij/cmux, env is native at no tier, so every open
1368
+ * rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
1369
+ * warning when there is no command to ride).
1370
+ * - **Split direction is explicit.** `otty pane split --right|--bottom` maps `pane:right`/`pane:down`.
1371
+ * - **`send-keys` mixes text and key tokens.** `otty pane send-keys --pane <id> -- "text" key:Enter`
1372
+ * can do both in one call. We implement `sendText` and `sendKeys` separately per the contract.
1373
+ * - **No pane geometry adapter.** `otty panes --json` does not report position, so `regions` is not
1374
+ * implementable. `template save` refuses on otty by naming the backend.
1375
+ * - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux, wezterm,
1376
+ * zellij, and cmux — this backend never binds a worktree to a workspace; callers fall back to
1377
+ * plain git plus `open()`.
1378
+ */
1379
+ function createOttyAdapter(deps) {
1380
+ const adapter = {
1381
+ name: "otty",
1382
+ canSizeSplits: false,
1383
+ open(exec, opts) {
1384
+ const at = opts.at ?? "tab";
1385
+ if (at === "workspace") {
1386
+ const args = ["open", "--new-window"];
1387
+ if (opts.cwd) args.push(opts.cwd);
1388
+ const out = exec("otty", args);
1389
+ if (!out) throw new Error(withReason(exec, "otty open --new-window failed"));
1390
+ const parsed = parseOttyOutput(out);
1391
+ if (!parsed.window_id) throw new Error("otty open --new-window did not report the window id");
1392
+ const paneId = parsed.pane_id;
1393
+ if (!paneId) throw new Error("otty open --new-window did not report the initial pane id");
1394
+ const opened = openedPane$1(paneId, parsed.tab_id, parsed.window_id);
1395
+ if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
1396
+ runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
1397
+ return opened;
1398
+ }
1399
+ if (at === "tab") {
1400
+ const args = ["tab", "new"];
1401
+ if (opts.cwd) args.push("--cwd", opts.cwd);
1402
+ const out = exec("otty", args);
1403
+ if (!out) throw new Error(withReason(exec, "otty tab new failed"));
1404
+ const parsed = parseOttyOutput(out);
1405
+ const paneId = parsed.pane_id;
1406
+ if (!paneId) throw new Error("otty tab new did not report the pane id");
1407
+ const opened = openedPane$1(paneId, parsed.tab_id, deps.window);
1408
+ if (opts.label) adapter.rename(exec, opened, "tab", opts.label);
1409
+ runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
1410
+ return opened;
1411
+ }
1412
+ if (at === "pane:float") refuseFloatingPane(adapter.name);
1413
+ if (opts.from) adapter.focus(exec, opts.from);
1414
+ const args = [
1415
+ "pane",
1416
+ "split",
1417
+ at === "pane:down" ? "--bottom" : "--right"
1418
+ ];
1419
+ if (opts.cwd) args.push("--cwd", opts.cwd);
1420
+ const out = exec("otty", args);
1421
+ if (!out) throw new Error(withReason(exec, "otty pane split failed"));
1422
+ const parsed = parseOttyOutput(out);
1423
+ const paneId = parsed.pane_id;
1424
+ if (!paneId) throw new Error("otty pane split did not report the pane id");
1425
+ const opened = openedPane$1(paneId, parsed.tab_id, deps.window);
1426
+ if (opts.label) adapter.rename(exec, opened, "pane", opts.label);
1427
+ runLaunch$2(adapter, exec, opened, opts.env, opts.launch);
1428
+ return opened;
1429
+ },
1430
+ rename(exec, target, tier, name) {
1431
+ if (tier === "tab") {
1432
+ exec("otty", [
1433
+ "tab",
1434
+ "rename",
1435
+ "--tab",
1436
+ target.id,
1437
+ "--title",
1438
+ name
1439
+ ]);
1440
+ return;
1441
+ }
1442
+ exec("otty", [
1443
+ "pane",
1444
+ "rename",
1445
+ "--pane",
1446
+ target.id,
1447
+ "--title",
1448
+ name
1449
+ ]);
1450
+ },
1451
+ group() {},
1452
+ sendText(exec, target, text) {
1453
+ exec("otty", [
1454
+ "pane",
1455
+ "send-keys",
1456
+ "--pane",
1457
+ target.id,
1458
+ "--",
1459
+ text
1460
+ ]);
1461
+ },
1462
+ sendKeys(exec, target, keys) {
1463
+ const keyTokens = keys.map((k) => `key:${toOttyKey(k)}`);
1464
+ exec("otty", [
1465
+ "pane",
1466
+ "send-keys",
1467
+ "--pane",
1468
+ target.id,
1469
+ "--",
1470
+ ...keyTokens
1471
+ ]);
1472
+ },
1473
+ submit(exec, target, text) {
1474
+ if (!text) {
1475
+ adapter.sendKeys(exec, target, ["Enter"]);
1476
+ return;
1477
+ }
1478
+ exec("otty", [
1479
+ "pane",
1480
+ "send-keys",
1481
+ "--pane",
1482
+ target.id,
1483
+ "--",
1484
+ text,
1485
+ "key:Enter"
1486
+ ]);
1487
+ },
1488
+ read(exec, target, opts) {
1489
+ const args = [
1490
+ "pane",
1491
+ "capture",
1492
+ "--pane",
1493
+ target.id
1494
+ ];
1495
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
1496
+ return { text: exec("otty", args) ?? "" };
1497
+ },
1498
+ waitForOutput(exec, target, opts) {
1499
+ return pollForOutput(adapter, exec, target, opts);
1500
+ },
1501
+ focus(exec, target) {
1502
+ exec("otty", [
1503
+ "pane",
1504
+ "focus",
1505
+ "--pane",
1506
+ target.id
1507
+ ]);
1508
+ },
1509
+ teardown(exec, target) {
1510
+ exec("otty", [
1511
+ "pane",
1512
+ "close",
1513
+ "--pane",
1514
+ target.id
1515
+ ]);
1516
+ },
1517
+ paneExists(exec, target) {
1518
+ return listOttyPanes(exec).some((p) => p.id === target.id);
1519
+ },
1520
+ isPaneFocused(exec, target) {
1521
+ const found = listOttyPanes(exec).find((p) => p.id === target.id);
1522
+ if (!found) return void 0;
1523
+ return found.is_focused === true;
1524
+ },
1525
+ listPanes(exec) {
1526
+ return listOttyPanes(exec).map((p) => {
1527
+ const pane = {
1528
+ id: p.id,
1529
+ mux: "otty",
1530
+ floating: false
1531
+ };
1532
+ if (p.cwd) pane.cwd = p.cwd;
1533
+ if (p.title) pane.label = p.title;
1534
+ return pane;
1535
+ });
1536
+ }
1537
+ };
1538
+ return adapter;
1539
+ }
1540
+ function parseOttyOutput(out) {
1541
+ try {
1542
+ return JSON.parse(out);
1543
+ } catch {
1544
+ return {};
1545
+ }
1546
+ }
1547
+ function listOttyPanes(exec) {
1548
+ const out = exec("otty", ["panes", "--json"]);
1549
+ if (!out) return [];
1550
+ let parsed;
1551
+ try {
1552
+ parsed = JSON.parse(out);
1553
+ } catch {
1554
+ return [];
1555
+ }
1556
+ if (!Array.isArray(parsed)) return [];
1557
+ const panes = [];
1558
+ for (const p of parsed) if (p?.pane_id) panes.push({
1559
+ id: p.pane_id,
1560
+ title: p.title,
1561
+ cwd: p.cwd,
1562
+ is_focused: p.is_focused
1563
+ });
1564
+ return panes;
1565
+ }
1566
+ function openedPane$1(paneId, tabId, window) {
1567
+ const opened = {
1568
+ id: paneId,
1569
+ tab: tabId ?? paneId
1570
+ };
1571
+ if (window) opened.workspace = window;
1572
+ return opened;
1573
+ }
1574
+ function runLaunch$2(adapter, exec, target, env, launch) {
1575
+ const fallback = envFallback(env, launch);
1576
+ if (fallback.kind === "dropped") {
1577
+ process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this otty pane — otty has no --env flag on pane split/tab new/open
1578
+ `);
1579
+ return;
1580
+ }
1581
+ if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
1582
+ }
1583
+ /**
1584
+ * The core key vocabulary's otty spelling. otty uses PascalCase key names: Enter, Tab, Escape, etc.
1585
+ */
1586
+ const OTTY_KEY_RENAMES = { "C-c": "Ctrl+c" };
1587
+ function toOttyKey(key) {
1588
+ return OTTY_KEY_RENAMES[key] ?? key;
1589
+ }
916
1590
  //#endregion
917
1591
  //#region src/mux.tmux.ts
918
1592
  /**
@@ -946,6 +1620,25 @@ const TMUX_TAB_NAME_OPTION = "@cm_tab";
946
1620
  const tmuxMuxAdapter = {
947
1621
  name: "tmux",
948
1622
  canSizeSplits: true,
1623
+ /**
1624
+ * `new-pane` opens a floating pane — tmux 3.7's own new command ("Add floating panes. These are
1625
+ * panes which sit above the layout ('tiled panes') like popups but unlike popups are not modal and
1626
+ * behave like panes", CHANGES 3.6b → 3.7), bound to `*` by default.
1627
+ *
1628
+ * Declared UNCONDITIONALLY rather than probed off `tmux -V`, and that is deliberate: this adapter
1629
+ * takes no version reading anywhere (its `-l`, `-e` and `@`-option paths are all declared the same
1630
+ * way), and a version probe would cost an exec on every resolution to pre-empt a failure tmux
1631
+ * already reports precisely. On tmux ≤ 3.6 the command does not exist and `new-pane` fails with
1632
+ * tmux's own `unknown command` — surfaced by the `withReason` throw in `open`, which names the
1633
+ * command that failed. A silent wrong-pane is the failure mode worth engineering against, and this
1634
+ * has none: there is nothing for an absent `new-pane` to be mistaken for.
1635
+ *
1636
+ * Both sides of this placement are now pinned against a real 3.7c binary — the read side by
1637
+ * `#{pane_floating_flag}`, the create side by the `new-pane` rows in `mux.tmux.integration.test.ts`.
1638
+ * The branch below was originally written off tmux's CHANGES file, because the tmux installed when
1639
+ * it landed was 3.6b and had no `new-pane` to run it against.
1640
+ */
1641
+ canFloatPanes: true,
949
1642
  open(exec, opts) {
950
1643
  const at = opts.at ?? "tab";
951
1644
  const window = at === "workspace" || at === "tab";
@@ -953,7 +1646,17 @@ const tmuxMuxAdapter = {
953
1646
  const group = window && opts.workspaceGroup != null;
954
1647
  const format = "#{pane_id} #{window_id}";
955
1648
  let args;
956
- if (window) args = [
1649
+ if (at === "pane:float") args = [
1650
+ "new-pane",
1651
+ ...opts.from ? ["-t", opts.from.id] : [],
1652
+ ...env,
1653
+ "-c",
1654
+ opts.cwd,
1655
+ "-P",
1656
+ "-F",
1657
+ format
1658
+ ];
1659
+ else if (window) args = [
957
1660
  "new-window",
958
1661
  "-d",
959
1662
  ...env,
@@ -1064,14 +1767,16 @@ const tmuxMuxAdapter = {
1064
1767
  ]);
1065
1768
  },
1066
1769
  read(exec, target, opts) {
1067
- const args = [
1068
- "capture-pane",
1069
- "-p",
1070
- "-t",
1071
- target.id
1072
- ];
1073
- if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
1074
- return exec("tmux", args) ?? "";
1770
+ const text = capturePane(exec, target, opts?.lines);
1771
+ if (!opts?.truncation) return { text };
1772
+ if (opts.lines === "all") return {
1773
+ text,
1774
+ truncated: false
1775
+ };
1776
+ return {
1777
+ text,
1778
+ truncated: isReadTruncated(text, capturePane(exec, target, (opts.lines ?? 0) + 1))
1779
+ };
1075
1780
  },
1076
1781
  waitForOutput(exec, target, opts) {
1077
1782
  return pollForOutput(tmuxMuxAdapter, exec, target, opts);
@@ -1145,14 +1850,15 @@ const tmuxMuxAdapter = {
1145
1850
  "list-panes",
1146
1851
  "-a",
1147
1852
  "-F",
1148
- "#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host}"
1853
+ "#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host} #{pane_floating_flag}"
1149
1854
  ]);
1150
1855
  if (!out) return [];
1151
1856
  return out.split("\n").filter(Boolean).map((line) => {
1152
- const [id, , cwd, title, host] = line.split(" ");
1857
+ const [id, , cwd, title, host, floating] = line.split(" ");
1153
1858
  const pane = {
1154
1859
  id: id ?? "",
1155
- mux: "tmux"
1860
+ mux: "tmux",
1861
+ floating: floating === "1"
1156
1862
  };
1157
1863
  if (cwd) pane.cwd = cwd;
1158
1864
  const label = paneLabel(title, host);
@@ -1331,6 +2037,22 @@ function toTmuxSize(ratio) {
1331
2037
  * key list here would make the passthrough a second vocabulary to maintain.
1332
2038
  */
1333
2039
  const TMUX_KEY_RENAMES = { Backspace: "BSpace" };
2040
+ /**
2041
+ * One spelling of the capture, taken by `read` for the snapshot AND for the one-row-deeper truncation
2042
+ * probe — so the two differ in exactly the number they disagree about and nothing else. `lines`
2043
+ * omitted is tmux's own default window: the visible screen, with no `-S` at all.
2044
+ */
2045
+ function capturePane(exec, target, lines) {
2046
+ const args = [
2047
+ "capture-pane",
2048
+ "-p",
2049
+ "-t",
2050
+ target.id
2051
+ ];
2052
+ if (lines === "all") args.push("-S", "-");
2053
+ else if (lines != null) args.push("-S", `-${lines}`);
2054
+ return exec("tmux", args) ?? "";
2055
+ }
1334
2056
  function toTmuxKey(key) {
1335
2057
  return TMUX_KEY_RENAMES[key] ?? key;
1336
2058
  }
@@ -1438,6 +2160,7 @@ function createWeztermAdapter(deps) {
1438
2160
  runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
1439
2161
  return opened;
1440
2162
  }
2163
+ if (at === "pane:float") refuseFloatingPane(adapter.name);
1441
2164
  const direction = at === "pane:down" ? ["--bottom"] : ["--right"];
1442
2165
  const from = opts.from ? ["--pane-id", opts.from.id] : [];
1443
2166
  const size = opts.ratio != null ? ["--percent", toWeztermSize(opts.ratio)] : [];
@@ -1501,14 +2224,16 @@ function createWeztermAdapter(deps) {
1501
2224
  adapter.sendKeys(exec, target, ["Enter"]);
1502
2225
  },
1503
2226
  read(exec, target, opts) {
1504
- const args = [
1505
- "cli",
1506
- "get-text",
1507
- "--pane-id",
1508
- target.id
1509
- ];
1510
- if (opts?.lines != null) args.push("--start-line", String(-opts.lines));
1511
- return exec("wezterm", args) ?? "";
2227
+ const text = getText(exec, target, opts?.lines);
2228
+ if (!opts?.truncation) return { text };
2229
+ if (opts.lines === "all") return {
2230
+ text,
2231
+ truncated: false
2232
+ };
2233
+ return {
2234
+ text,
2235
+ truncated: isReadTruncated(text, getText(exec, target, (opts.lines ?? 0) + 1))
2236
+ };
1512
2237
  },
1513
2238
  waitForOutput(exec, target, opts) {
1514
2239
  return pollForOutput(adapter, exec, target, opts);
@@ -1537,7 +2262,8 @@ function createWeztermAdapter(deps) {
1537
2262
  return listWeztermPanes(exec).map((p) => {
1538
2263
  const pane = {
1539
2264
  id: String(p.pane_id),
1540
- mux: "wezterm"
2265
+ mux: "wezterm",
2266
+ floating: false
1541
2267
  };
1542
2268
  const cwd = weztermCwd(p.cwd);
1543
2269
  if (cwd) pane.cwd = cwd;
@@ -1673,6 +2399,26 @@ const WEZTERM_KEY_BYTES = {
1673
2399
  PageUp: "\x1B[5~",
1674
2400
  PageDown: "\x1B[6~"
1675
2401
  };
2402
+ /**
2403
+ * One spelling of the capture, taken by `read` for the snapshot AND for its one-row-deeper truncation
2404
+ * probe, so the two differ only in the number they are meant to disagree about.
2405
+ *
2406
+ * `--start-line` counts backward into scrollback from 0 (the top of the visible screen); the end
2407
+ * defaults to the bottom of the screen. Negative-N approximates "last N lines" the way tmux's `-S -N`
2408
+ * does, though the two are not guaranteed to line up cell-for-cell. `lines` omitted is WezTerm's own
2409
+ * default window — the visible screen, with no `--start-line` at all.
2410
+ */
2411
+ function getText(exec, target, lines) {
2412
+ const args = [
2413
+ "cli",
2414
+ "get-text",
2415
+ "--pane-id",
2416
+ target.id
2417
+ ];
2418
+ const start = lines === "all" ? FULL_SCROLLBACK_LINES : lines;
2419
+ if (start != null) args.push("--start-line", String(-start));
2420
+ return exec("wezterm", args) ?? "";
2421
+ }
1676
2422
  //#endregion
1677
2423
  //#region src/mux.zellij.ts
1678
2424
  /**
@@ -1687,13 +2433,40 @@ const WEZTERM_KEY_BYTES = {
1687
2433
  * binary these commands fail and the adapter surfaces the failure rather than silently mis-targeting
1688
2434
  * the focused pane.
1689
2435
  *
1690
- * Probed from the Zellij docs + CHANGELOG only — Zellij is not installed in this sandbox, so nothing
1691
- * here carries the "verified against a live binary" claim `session.tmux.ts`/`session.herdr.ts` make;
1692
- * it makes the same honest disclaimer `mux.wezterm.ts` does. Two literals in particular are worth a
1693
- * one-line confirmation on a live 0.44.1 binary — the exact form `new-pane` prints an id in (bare `3`
1694
- * vs `terminal_3`) and the shell value of `$ZELLIJ_PANE_ID`. Both are handled either way: ids are
1695
- * carried verbatim and compared through `samePane`, which treats a bare `N` and its `terminal_N` twin
1696
- * as the same pane (per the docs' own `terminal_N | plugin_N | bare N` scheme).
2436
+ * Originally probed from the Zellij docs + CHANGELOG alone; now **driven against a live 0.44.3
2437
+ * binary** by `mux.zellij.integration.test.ts`, so the command shapes below carry the same
2438
+ * verified-against-a-real-binary claim `session.tmux.ts`/`session.herdr.ts` make. What the live probe
2439
+ * changed, and what a doc probe could not have known:
2440
+ *
2441
+ * - The id forms are confirmed and asymmetric: `new-pane` prints the PREFIXED `terminal_N`, while
2442
+ * `list-panes --json` reports `id` as a BARE integer. `samePane` is what makes those the same pane,
2443
+ * so it is load-bearing rather than defensive.
2444
+ * - That bare integer is NOT unique: a live session reports `id: 0` for both its suppressed
2445
+ * `zellij:link` PLUGIN pane and its first terminal pane. The number is unique only within a kind,
2446
+ * and `is_plugin` is what says which kind — so `listZellijPanes` qualifies every bare id to
2447
+ * `terminal_N`/`plugin_N` before anything compares or reports it. Both prefixed forms are what
2448
+ * `--pane-id` itself accepts, and a bare `N` addresses the TERMINAL pane (verified: renaming
2449
+ * `--pane-id 3` renames terminal 3 and leaves plugin 3 alone), which is exactly the fold
2450
+ * `normalizePaneId` makes.
2451
+ * - Which `list-panes --json` field names are real, and what each one carries. The label guard reads
2452
+ * `terminal_command`, not `pane_command` — a doc probe had that one backwards; see `ZellijPane`.
2453
+ * `pane_cwd` and `pane_command` are real too, and both are present ONLY on a terminal pane: a
2454
+ * plugin pane's record omits the keys entirely, which is how a probe that sampled one concluded
2455
+ * the fields did not exist at all. That is the failure mode of a doc probe, and the reason this
2456
+ * file now has a suite.
2457
+ * - `new-pane --direction` requires an attached client focused on a TERMINAL pane, and fails
2458
+ * SILENTLY without one — it prints a plausible new pane id and exits 0 having created nothing.
2459
+ * Re-probed against 0.44.3 with the client parked on zellij's own release-notes PLUGIN pane, which
2460
+ * is where a fresh attach lands: the split printed `terminal_2`, exited 0, and the pane count did
2461
+ * not move. `open()` does not propagate that phantom — see `openedForPane`, which now believes a
2462
+ * reported id only where it names a pane that was NOT already standing.
2463
+ * - **A `zellij action` reply can be delivered to the WRONG command**, and **a session no client has
2464
+ * ever attached to reports `list-panes --json` as an empty ARRAY** rather than as an error. Both
2465
+ * are the reason this file retries a listing instead of reading one empty answer as the truth; see
2466
+ * `LIST_PANES_ATTEMPTS` for the repro behind the first and `mux.zellij.integration.test.ts`'s
2467
+ * readiness gate for the second. So the older claim that every verb but `--direction` works against
2468
+ * a client-less session is wrong: the listing is silent there, and everything in this file that
2469
+ * resolves an id reads the listing.
1697
2470
  *
1698
2471
  * Real capability shape that fell out of the probe:
1699
2472
  *
@@ -1742,6 +2515,7 @@ const WEZTERM_KEY_BYTES = {
1742
2515
  function createZellijAdapter(deps) {
1743
2516
  const adapter = {
1744
2517
  name: "zellij",
2518
+ canFloatPanes: true,
1745
2519
  open(exec, opts) {
1746
2520
  const at = opts.at ?? "tab";
1747
2521
  if (at === "tab" || at === "workspace") {
@@ -1752,11 +2526,10 @@ function createZellijAdapter(deps) {
1752
2526
  opts.cwd
1753
2527
  ];
1754
2528
  if (opts.label) args.push("--name", opts.label);
2529
+ const before = paneIdSet(exec);
1755
2530
  const out = exec("zellij", args);
1756
- if (!out) throw new Error(withReason(exec, "zellij action new-tab failed"));
1757
- const tabId = out.trim();
1758
- if (!tabId) throw new Error("zellij action new-tab did not report the new tab id");
1759
- const opened = openedForTab(exec, tabId, deps.session);
2531
+ if (out === null) throw new Error(withReason(exec, "zellij action new-tab failed"));
2532
+ const opened = openedForTab(exec, out.trim(), before, deps.session);
1760
2533
  runLaunch(adapter, exec, opened, opts.env, opts.launch);
1761
2534
  return opened;
1762
2535
  }
@@ -1764,17 +2537,15 @@ function createZellijAdapter(deps) {
1764
2537
  const args = [
1765
2538
  "action",
1766
2539
  "new-pane",
1767
- "--direction",
1768
- at === "pane:down" ? "down" : "right",
2540
+ ...at === "pane:float" ? ["--floating"] : ["--direction", at === "pane:down" ? "down" : "right"],
1769
2541
  "--cwd",
1770
2542
  opts.cwd
1771
2543
  ];
1772
2544
  if (opts.label) args.push("--name", opts.label);
2545
+ const before = paneIdSet(exec);
1773
2546
  const out = exec("zellij", args);
1774
- if (!out) throw new Error(withReason(exec, "zellij action new-pane failed"));
1775
- const paneId = out.trim();
1776
- if (!paneId) throw new Error("zellij action new-pane did not report the new pane id");
1777
- const opened = openedForPane(exec, paneId, deps.session);
2547
+ if (out === null) throw new Error(withReason(exec, "zellij action new-pane failed"));
2548
+ const opened = openedForPane(exec, out.trim(), before, deps.session);
1778
2549
  runLaunch(adapter, exec, opened, opts.env, opts.launch);
1779
2550
  return opened;
1780
2551
  },
@@ -1824,19 +2595,50 @@ function createZellijAdapter(deps) {
1824
2595
  adapter.sendKeys(exec, target, ["Enter"]);
1825
2596
  },
1826
2597
  read(exec, target, opts) {
1827
- if (opts?.lines != null) return lastLines(exec("zellij", [
1828
- "action",
1829
- "dump-screen",
1830
- "--pane-id",
1831
- target.id,
1832
- "--full"
1833
- ]) ?? "", opts.lines);
1834
- return exec("zellij", [
2598
+ if (opts?.lines === "all") {
2599
+ const text = exec("zellij", [
2600
+ "action",
2601
+ "dump-screen",
2602
+ "--pane-id",
2603
+ target.id,
2604
+ "--full"
2605
+ ]) ?? "";
2606
+ return opts.truncation ? {
2607
+ text,
2608
+ truncated: false
2609
+ } : { text };
2610
+ }
2611
+ if (opts?.lines != null) {
2612
+ const full = exec("zellij", [
2613
+ "action",
2614
+ "dump-screen",
2615
+ "--pane-id",
2616
+ target.id,
2617
+ "--full"
2618
+ ]) ?? "";
2619
+ const text = lastLines(full, opts.lines);
2620
+ return opts.truncation ? {
2621
+ text,
2622
+ truncated: isReadTruncated(text, full)
2623
+ } : { text };
2624
+ }
2625
+ const text = exec("zellij", [
1835
2626
  "action",
1836
2627
  "dump-screen",
1837
2628
  "--pane-id",
1838
2629
  target.id
1839
2630
  ]) ?? "";
2631
+ if (!opts?.truncation) return { text };
2632
+ return {
2633
+ text,
2634
+ truncated: isReadTruncated(text, exec("zellij", [
2635
+ "action",
2636
+ "dump-screen",
2637
+ "--pane-id",
2638
+ target.id,
2639
+ "--full"
2640
+ ]) ?? "")
2641
+ };
1840
2642
  },
1841
2643
  waitForOutput(exec, target, opts) {
1842
2644
  return pollForOutput(adapter, exec, target, opts);
@@ -1868,10 +2670,11 @@ function createZellijAdapter(deps) {
1868
2670
  return listZellijPanes(exec).map((p) => {
1869
2671
  const pane = {
1870
2672
  id: p.id,
1871
- mux: "zellij"
2673
+ mux: "zellij",
2674
+ floating: p.is_floating === true
1872
2675
  };
1873
2676
  if (p.pane_cwd) pane.cwd = p.pane_cwd;
1874
- const label = zellijLabel(p.title, p.pane_command);
2677
+ const label = zellijLabel(p.title, p.terminal_command);
1875
2678
  if (label) pane.label = label;
1876
2679
  return pane;
1877
2680
  });
@@ -1881,49 +2684,158 @@ function createZellijAdapter(deps) {
1881
2684
  }
1882
2685
  const zellijMuxAdapter = createZellijAdapter({});
1883
2686
  /**
1884
- * One `zellij action list-panes --json` call, parsed defensively — never throws on bad output. The id
1885
- * is coerced to a string so a bare-integer id (`3`) and a prefixed one (`terminal_3`) are compared as
1886
- * strings by `samePane` rather than one being a number.
2687
+ * How many times a listing is re-asked before it is reported as empty, and how many times an open
2688
+ * re-looks for the pane it just made.
2689
+ *
2690
+ * Both exist for ONE verified defect in zellij 0.44.3's CLI, which no amount of adapter care can
2691
+ * prevent and every verb here rides on: **a `zellij action` reply can be delivered to the wrong
2692
+ * command.** Driven under CPU contention, a command exits 0 having printed NOTHING, and the reply it
2693
+ * should have received arrives on the stdout of the command issued after it. Reproduced by
2694
+ * alternating `new-tab` and `list-panes --json` 40 times on a loaded 2-core box — twice in 40, the
2695
+ * `new-tab` printed an empty string and the `list-panes` that followed it printed `27`. Two hundred
2696
+ * back-to-back `list-panes --json` calls with no mutating verb between them lost nothing, so it is
2697
+ * the mutating verbs that open the window.
2698
+ *
2699
+ * The consequences land squarely here. An empty reply to `list-panes --json` is not an empty session
2700
+ * — a live zellij session always has at least one pane — so reading it as one made every id
2701
+ * resolution in this file fail at once. And an id printed by `new-pane`/`new-tab` may belong to the
2702
+ * PREVIOUS command, so it can name a pane that has been standing all along.
2703
+ *
2704
+ * A retry is the honest remedy because the loss is per-call and independent: the same read reissued
2705
+ * answers. These are ceilings on a wedged server, not a wait that decides a pass.
2706
+ *
2707
+ * What a retry CANNOT reach, stated so a caller knows the edge: a misdelivered reply may also be a
2708
+ * valid pane array — an older one, which simply does not carry a pane opened since. Nothing in the
2709
+ * shape of that answer marks it stale, so `listPanes` cannot reject it and a single negative read
2710
+ * (`paneExists`, `isPaneFocused`) can be wrong. A caller that needs certainty on this backend re-asks;
2711
+ * `mux.zellij.integration.test.ts` does exactly that.
1887
2712
  */
1888
- function listZellijPanes(exec) {
2713
+ const LIST_PANES_ATTEMPTS = 3;
2714
+ const OPEN_RESOLVE_ATTEMPTS = 10;
2715
+ /**
2716
+ * One `zellij action list-panes --json` read, parsed defensively — `undefined` for output that is not
2717
+ * a pane array, so a caller can tell "zellij did not answer" from "zellij answered, with no panes".
2718
+ * Every id is QUALIFIED on the way out (see `zellijPaneId`), so what this returns — and therefore
2719
+ * what `LivePane.id` carries and what `samePane` compares — names exactly one live pane.
2720
+ */
2721
+ function readZellijPanes(exec) {
1889
2722
  const out = exec("zellij", [
1890
2723
  "action",
1891
2724
  "list-panes",
1892
2725
  "--json"
1893
2726
  ]);
1894
- if (!out) return [];
2727
+ if (!out) return void 0;
1895
2728
  let parsed;
1896
2729
  try {
1897
2730
  parsed = JSON.parse(out);
1898
2731
  } catch {
1899
- return [];
2732
+ return;
1900
2733
  }
1901
- if (!Array.isArray(parsed)) return [];
2734
+ if (!Array.isArray(parsed)) return void 0;
1902
2735
  return parsed.filter((p) => p != null && p.id != null).map((p) => ({
1903
2736
  ...p,
1904
- id: String(p.id)
2737
+ id: zellijPaneId(p.id, p.is_plugin)
1905
2738
  }));
1906
2739
  }
1907
2740
  /**
1908
- * The `OpenedPane` for a pane `new-pane` just reported — its tab resolved from the one `list-panes`
1909
- * record carrying that pane id, and the workspace filled from the ambient session name. Throws rather
1910
- * than guessing a tab: `OpenedPane.tab` is required (every multiplexer has the Tab level), and a wrong
1911
- * tab is worse than a loud failure.
2741
+ * The id a listing record is reported under — qualified by KIND, because zellij's bare number is not
2742
+ * unique. Plugin panes and terminal panes are numbered in separate spaces, so a live session reports
2743
+ * `id: 0` for both its suppressed `zellij:link` plugin pane and its first terminal pane; reporting
2744
+ * both as `'0'` collapsed two genuinely different panes onto one `LivePane.id`, and every resolution
2745
+ * by id — `paneExists`, `isPaneFocused`, and `openedForPane`'s guard against a phantom `new-pane`
2746
+ * result — could then land on the wrong one.
2747
+ *
2748
+ * A bare integer therefore takes the `terminal_`/`plugin_` prefix its `is_plugin` names, which is the
2749
+ * form `new-pane` already prints and the form `--pane-id` accepts for BOTH kinds (verified against a
2750
+ * live 0.44.3). An id that already carries a prefix is left exactly as it is — this qualifies what
2751
+ * zellij left ambiguous, it does not rewrite what zellij spelled out.
2752
+ *
2753
+ * A record with no `is_plugin` at all is read as a terminal pane: that is the same answer
2754
+ * `normalizePaneId` gives a bare id, and the kind zellij's own bare-id addressing resolves to.
2755
+ */
2756
+ function zellijPaneId(id, isPlugin) {
2757
+ const raw = String(id);
2758
+ if (!/^\d+$/.test(raw)) return raw;
2759
+ return isPlugin === true ? `plugin_${raw}` : `terminal_${raw}`;
2760
+ }
2761
+ /**
2762
+ * The session's panes — never throws on bad output, and never reports a LOST reply as an empty
2763
+ * session. A read that did not come back as a pane array is simply re-asked (`LIST_PANES_ATTEMPTS`);
2764
+ * only a session that refuses every time answers `[]`, which is then a real answer rather than a
2765
+ * dropped one. See `LIST_PANES_ATTEMPTS` for the defect this stands in front of.
1912
2766
  */
1913
- function openedForPane(exec, paneId, session) {
1914
- const found = listZellijPanes(exec).find((p) => samePane(p.id, paneId));
1915
- if (!found || found.tab_id == null) throw new Error(`zellij did not report a tab for the new pane ${paneId}`);
1916
- return openedPane(paneId, String(found.tab_id), session);
2767
+ function listZellijPanes(exec) {
2768
+ for (let attempt = 1; attempt <= LIST_PANES_ATTEMPTS; attempt++) {
2769
+ const panes = readZellijPanes(exec);
2770
+ if (panes) return panes;
2771
+ }
2772
+ return [];
1917
2773
  }
1918
2774
  /**
1919
- * The `OpenedPane` for a tab `new-tab` just reported — its initial pane resolved as the single
1920
- * `list-panes` record carrying that tab id. Throws rather than guessing: a new tab must have a pane,
1921
- * and a caller handed a tab with no pane could neither drive nor name it.
2775
+ * The panes standing right now, keyed the way `samePane` compares them — an open's BEFORE side. A
2776
+ * pane already in this set cannot be the one the open just made, which is the whole check.
2777
+ */
2778
+ function paneIdSet(exec) {
2779
+ return new Set(listZellijPanes(exec).map((p) => normalizePaneId(p.id)));
2780
+ }
2781
+ /**
2782
+ * The TERMINAL panes that appeared since `before` — the only candidates an open can have made.
2783
+ *
2784
+ * Plugin panes are excluded because zellij loads them on its own schedule, not the caller's: a tab
2785
+ * opened by `new-tab` can be carrying a plugin pane in the same listing as its own initial pane, and
2786
+ * that plugin record can sort FIRST. Resolving an open to it hands the caller a `plugin_N` — an id
2787
+ * that exists, so nothing downstream refuses it, and that then answers nothing a pane is asked. Seen
2788
+ * at the real boundary: an `open()` at `tab` returned `plugin_15`, and the `rename()` that followed
2789
+ * renamed a pane the caller never opened. `new-tab` and `new-pane` both create a TERMINAL pane, so
2790
+ * the kind is the discriminator, and `is_plugin` is the field that carries it.
1922
2791
  */
1923
- function openedForTab(exec, tabId, session) {
1924
- const pane = listZellijPanes(exec).find((p) => p.tab_id != null && String(p.tab_id) === tabId);
1925
- if (!pane) throw new Error(`zellij did not report a pane in the new tab ${tabId}`);
1926
- return openedPane(pane.id, tabId, session);
2792
+ function appearedTerminals(exec, before) {
2793
+ return listZellijPanes(exec).filter((p) => p.is_plugin !== true && !before.has(normalizePaneId(p.id)));
2794
+ }
2795
+ /**
2796
+ * The `OpenedPane` for a pane `new-pane` just made — resolved against the listing rather than taken
2797
+ * on the word of the id zellij printed, because that word is not reliable (see
2798
+ * `LIST_PANES_ATTEMPTS`): the reply may be empty, or may be the PREVIOUS command's, in which case
2799
+ * `paneId` names a pane that has been standing all along.
2800
+ *
2801
+ * So the id is believed only where it names a pane ABSENT from `before` — the phantom guard this file
2802
+ * has always meant to be, now closed. Where it cannot be believed, the session itself answers: a
2803
+ * single pane that appeared over this open is unambiguous, and adopting it recovers a reply zellij
2804
+ * lost instead of failing an open that genuinely happened. That fallback waits one round, so a
2805
+ * printed id that is merely SLOW to appear still wins over a guess.
2806
+ *
2807
+ * Throws rather than guessing a tab: `OpenedPane.tab` is required (every multiplexer has the Tab
2808
+ * level), and a wrong tab is worse than a loud failure.
2809
+ */
2810
+ function openedForPane(exec, paneId, before, session) {
2811
+ for (let attempt = 1; attempt <= OPEN_RESOLVE_ATTEMPTS; attempt++) {
2812
+ const appeared = appearedTerminals(exec, before);
2813
+ const claimed = paneId ? appeared.find((p) => samePane(p.id, paneId)) : void 0;
2814
+ if (claimed?.tab_id != null) return openedPane(paneId, String(claimed.tab_id), session);
2815
+ const only = appeared.length === 1 ? appeared[0] : void 0;
2816
+ if ((attempt > 1 || !paneId) && only?.tab_id != null) return openedPane(only.id, String(only.tab_id), session);
2817
+ }
2818
+ throw new Error(paneId ? `zellij did not report a tab for the new pane ${paneId}` : "zellij action new-pane did not report the new pane id, and no new pane appeared");
2819
+ }
2820
+ /**
2821
+ * The `OpenedPane` for a tab `new-tab` just made — its initial pane resolved as the `list-panes`
2822
+ * record that carries that tab id AND was not already standing. Same reply-delivery defect, same
2823
+ * shape of answer as `openedForPane`: a `tabId` that names a tab whose panes all predate the open is
2824
+ * a stale reply, not this tab, and a single pane that appeared over the open answers when the id
2825
+ * cannot.
2826
+ *
2827
+ * Throws rather than guessing: a new tab must have a pane, and a caller handed a tab with no pane
2828
+ * could neither drive nor name it.
2829
+ */
2830
+ function openedForTab(exec, tabId, before, session) {
2831
+ for (let attempt = 1; attempt <= OPEN_RESOLVE_ATTEMPTS; attempt++) {
2832
+ const appeared = appearedTerminals(exec, before);
2833
+ const claimed = tabId ? appeared.find((p) => p.tab_id != null && String(p.tab_id) === tabId) : void 0;
2834
+ if (claimed) return openedPane(claimed.id, tabId, session);
2835
+ const only = appeared.length === 1 ? appeared[0] : void 0;
2836
+ if ((attempt > 1 || !tabId) && only?.tab_id != null) return openedPane(only.id, String(only.tab_id), session);
2837
+ }
2838
+ throw new Error(tabId ? `zellij did not report a pane in the new tab ${tabId}` : "zellij action new-tab did not report the new tab id, and no new pane appeared");
1927
2839
  }
1928
2840
  /**
1929
2841
  * Assemble an `OpenedPane`, attaching `workspace` only when the ambient session name is known — the
@@ -1974,7 +2886,7 @@ function toZellijKey(key) {
1974
2886
  }
1975
2887
  /**
1976
2888
  * A Zellij pane's label — its title, unless that title is the running command Zellij handed an unnamed
1977
- * pane. Zellij defaults an unnamed pane's title to its `pane_command`, so a title equal to it is
2889
+ * pane. Zellij defaults an unnamed pane's title to its `terminal_command`, so a title equal to it is
1978
2890
  * ambient rather than chosen; exporting it would put the same manufactured name on every shell pane,
1979
2891
  * exactly the collision tmux's hostname guard exists to prevent. A title that differs from the command
1980
2892
  * is one someone set (`new-pane --name`/`rename-pane`), so it is the author's and survives.
@@ -2007,6 +2919,8 @@ const KNOWN_MUX = [
2007
2919
  "herdr",
2008
2920
  "wezterm",
2009
2921
  "zellij",
2922
+ "cmux",
2923
+ "otty",
2010
2924
  "screen",
2011
2925
  "none"
2012
2926
  ];
@@ -2019,25 +2933,29 @@ function isKnownMux(v) {
2019
2933
  * every pane (its own bare-integer id) — per the issue that requested that backend (#47), the same
2020
2934
  * fast-path extension `$TMUX_PANE`/`$HERDR_PANE_ID` already get; Zellij exports `$ZELLIJ_PANE_ID` in
2021
2935
  * every terminal pane (its own `terminal_N`/bare-`N` id) — per the issue that requested this backend
2022
- * (#46). screen carries no per-pane env var. Both the ancestry probe and the `currentPane`
2023
- * self-identity helper read the pane through this table so the two never diverge on which env var a
2024
- * given mux uses.
2936
+ * (#46); cmux exports `$CMUX_SURFACE_ID` in every terminal (its surface ref, e.g. `surface:7`) — per
2937
+ * the issue that requested this backend (#48). screen carries no per-pane env var. Both the ancestry
2938
+ * probe and the `currentPane` self-identity helper read the pane through this table so the two never
2939
+ * diverge on which env var a given mux uses.
2025
2940
  */
2026
2941
  const PANE_ENV = {
2027
2942
  tmux: (env) => env["TMUX_PANE"],
2028
2943
  herdr: (env) => env["HERDR_PANE_ID"],
2029
2944
  wezterm: (env) => env["WEZTERM_PANE"],
2030
- zellij: (env) => env["ZELLIJ_PANE_ID"]
2945
+ zellij: (env) => env["ZELLIJ_PANE_ID"],
2946
+ cmux: (env) => env["CMUX_SURFACE_ID"],
2947
+ otty: (env) => env["OTTY_PANE_ID"]
2031
2948
  };
2032
2949
  /**
2033
2950
  * Resolve THIS session's own pane from env alone (no `ps` walk): the `$CYBER_MUX_PANE` fast-path a
2034
2951
  * spawn propagates → `$TMUX_PANE` (tmux) → `$HERDR_PANE_ID` (herdr) → `$WEZTERM_PANE` (wezterm) →
2035
- * `$ZELLIJ_PANE_ID` (zellij). Returns the pane tagged with its multiplexer, or undefined when the
2036
- * session is in no pane-carrying multiplexer. This is the mux-agnostic self-identity key.
2952
+ * `$ZELLIJ_PANE_ID` (zellij) → `$CMUX_SURFACE_ID` (cmux) → `$OTTY_PANE_ID` (otty). Returns the pane
2953
+ * tagged with its multiplexer, or undefined when the session is in no pane-carrying multiplexer.
2954
+ * This is the mux-agnostic self-identity key.
2037
2955
  */
2038
2956
  function currentPane(env) {
2039
2957
  if (env["CYBER_MUX_PANE"]) return {
2040
- mux: env["CYBER_MUX"] === "herdr" ? "herdr" : env["CYBER_MUX"] === "wezterm" ? "wezterm" : env["CYBER_MUX"] === "zellij" ? "zellij" : "tmux",
2958
+ mux: env["CYBER_MUX"] === "herdr" ? "herdr" : env["CYBER_MUX"] === "wezterm" ? "wezterm" : env["CYBER_MUX"] === "zellij" ? "zellij" : env["CYBER_MUX"] === "cmux" ? "cmux" : env["CYBER_MUX"] === "otty" ? "otty" : "tmux",
2041
2959
  pane: env["CYBER_MUX_PANE"]
2042
2960
  };
2043
2961
  const tmux = PANE_ENV.tmux(env);
@@ -2060,6 +2978,16 @@ function currentPane(env) {
2060
2978
  mux: "zellij",
2061
2979
  pane: zellij
2062
2980
  };
2981
+ const cmux = PANE_ENV.cmux(env);
2982
+ if (cmux) return {
2983
+ mux: "cmux",
2984
+ pane: cmux
2985
+ };
2986
+ const otty = PANE_ENV.otty(env);
2987
+ if (otty) return {
2988
+ mux: "otty",
2989
+ pane: otty
2990
+ };
2063
2991
  }
2064
2992
  /**
2065
2993
  * Two-mode multiplexer detection.
@@ -2115,7 +3043,7 @@ const MUX_COMM = [
2115
3043
  ];
2116
3044
  /** The per-pane env var for a mux, via the shared `PANE_ENV` table; undefined for screen/none. */
2117
3045
  function paneFor(mux, env) {
2118
- return mux === "tmux" || mux === "herdr" || mux === "wezterm" || mux === "zellij" ? PANE_ENV[mux](env) : void 0;
3046
+ return mux === "tmux" || mux === "herdr" || mux === "wezterm" || mux === "zellij" || mux === "cmux" ? PANE_ENV[mux](env) : void 0;
2119
3047
  }
2120
3048
  /**
2121
3049
  * An ancestry-discovered probe, OMITTING `pane` when the mux carries none — never carrying it as an
@@ -2161,6 +3089,8 @@ function discoverByAncestry(exec, env) {
2161
3089
  if (env["HERDR_ENV"]) return ancestryProbe("herdr", env);
2162
3090
  if (env["WEZTERM_PANE"]) return ancestryProbe("wezterm", env);
2163
3091
  if (env["ZELLIJ"]) return ancestryProbe("zellij", env);
3092
+ if (env["CMUX_WORKSPACE_ID"]) return ancestryProbe("cmux", env);
3093
+ if (env["OTTY_PANE_ID"]) return ancestryProbe("otty", env);
2164
3094
  return {
2165
3095
  mux: "none",
2166
3096
  via: "ancestry"
@@ -2214,14 +3144,14 @@ async function nudge(adapter, exec, target, message, opts = {}) {
2214
3144
  if (!adapter.paneExists(exec, target)) throw new Error(`nudge failed: pane ${target.id} no longer exists — the peer's session is gone, not busy.`);
2215
3145
  adapter.submit(exec, target, message);
2216
3146
  await sleep(settleMs);
2217
- if (!isStaged(adapter.read(exec, target), message)) return {
3147
+ if (!isStaged(adapter.read(exec, target).text, message)) return {
2218
3148
  taken: true,
2219
3149
  resubmits: 0
2220
3150
  };
2221
3151
  for (let attempt = 1; attempt <= attempts; attempt++) {
2222
3152
  adapter.submit(exec, target);
2223
3153
  await sleep(settleMs);
2224
- if (!isStaged(adapter.read(exec, target), message)) return {
3154
+ if (!isStaged(adapter.read(exec, target).text, message)) return {
2225
3155
  taken: true,
2226
3156
  resubmits: attempt
2227
3157
  };
@@ -2233,14 +3163,15 @@ async function nudge(adapter, exec, target, message, opts = {}) {
2233
3163
  /**
2234
3164
  * Resolve the raw backend for the multiplexer this process is inside, via the two-mode mux probe
2235
3165
  * (`$CYBER_MUX` fast-path/override, else ancestry discovery from `$$` falling back to the
2236
- * `$TMUX`/`$HERDR_ENV`/`$WEZTERM_PANE`/`$ZELLIJ` hint when the walk is inconclusive) —
2237
- * tmux/herdr/wezterm/zellij map to their adapters; anything else throws, because a caller asking to
2238
- * drive panes with no multiplexer has an unmet precondition, and a loud, actionable failure beats a
2239
- * silent no-op.
3166
+ * `$TMUX`/`$HERDR_ENV`/`$WEZTERM_PANE`/`$ZELLIJ`/`$CMUX_WORKSPACE_ID` hint when the walk is
3167
+ * inconclusive) — tmux/herdr/wezterm/zellij/cmux map to their adapters; anything else throws,
3168
+ * because a caller asking to drive panes with no multiplexer has an unmet precondition, and a loud,
3169
+ * actionable failure beats a silent no-op.
2240
3170
  *
2241
3171
  * The zellij adapter is bound to the ambient session name (`$ZELLIJ_SESSION_NAME`) at resolution — it
2242
3172
  * reports that as `OpenedPane.workspace`, so the session name has to come from the SAME `env` the
2243
- * probe reads. A missing name simply omits `workspace`; see `createZellijAdapter`.
3173
+ * probe reads. A missing name simply omits `workspace`; see `createZellijAdapter`. The cmux adapter
3174
+ * is similarly bound to the ambient workspace id (`$CMUX_WORKSPACE_ID`).
2244
3175
  *
2245
3176
  * `screen` is a KNOWN mux — the probe detects it (ancestry) and honors it as an override (fast-path)
2246
3177
  * — but it is NOT a drivable backend, so it throws its OWN message naming the reason rather than the
@@ -2266,8 +3197,10 @@ function resolveMuxAdapter(env, exec = nodeExec) {
2266
3197
  if (probe.mux === "herdr") return herdrMuxAdapter;
2267
3198
  if (probe.mux === "wezterm") return weztermMuxAdapter;
2268
3199
  if (probe.mux === "zellij") return createZellijAdapter({ session: env["ZELLIJ_SESSION_NAME"] });
2269
- if (probe.mux === "screen") throw new Error("cyber-mux detected GNU Screen, which it cannot drive: Screen addresses its split regions positionally (no per-pane id) and leaves $WINDOW unset in panes opened via `screen -X`, so a pane has no stable identity to send to, read from, or self-identify by. Run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), or zellij ($ZELLIJ set) instead.");
2270
- throw new Error("cyber-mux requires a session backend — run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), or zellij ($ZELLIJ set)");
3200
+ if (probe.mux === "cmux") return createCmuxAdapter({ workspace: env["CMUX_WORKSPACE_ID"] });
3201
+ if (probe.mux === "otty") return createOttyAdapter({ window: void 0 });
3202
+ if (probe.mux === "screen") throw new Error("cyber-mux detected GNU Screen, which it cannot drive: Screen addresses its split regions positionally (no per-pane id) and leaves $WINDOW unset in panes opened via `screen -X`, so a pane has no stable identity to send to, read from, or self-identify by. Run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), zellij ($ZELLIJ set), or cmux ($CMUX_WORKSPACE_ID set) instead.");
3203
+ throw new Error("cyber-mux requires a session backend — run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), zellij ($ZELLIJ set), cmux ($CMUX_WORKSPACE_ID set), or otty ($OTTY_PANE_ID set)");
2271
3204
  }
2272
3205
  /**
2273
3206
  * Resolve the multiplexer this process is inside and return it as a `MuxSession` with `exec` BOUND —
@@ -2340,6 +3273,6 @@ function callerPane(adapter, env) {
2340
3273
  return self && self.mux === adapter.name ? { id: self.pane } : void 0;
2341
3274
  }
2342
3275
  //#endregion
2343
- export { DEFAULT_WAIT_POLL_MS as _, nudge as a, pollForOutput as b, createZellijAdapter as c, weztermMuxAdapter as d, nodeNewId as f, herdrMuxAdapter as g, tmuxMuxAdapter as h, isStaged as i, zellijMuxAdapter as l, TMUX_WORKSPACE_GROUP_OPTION as m, resolveMux as n, currentPane as o, TMUX_TAB_NAME_OPTION as p, resolveMuxAdapter as r, probeMultiplexer as s, callerPane as t, createWeztermAdapter as u, assertWaitPattern as v, envFallback as x, matchWaitPattern as y };
3276
+ export { pollForOutput as C, envFallback as D, refuseFloatingPane as E, matchWaitPattern as S, canFloatPanes as T, FULL_SCROLLBACK_LINES as _, nudge as a, DEFAULT_WAIT_POLL_MS as b, createZellijAdapter as c, weztermMuxAdapter as d, nodeNewId as f, herdrMuxAdapter as g, tmuxMuxAdapter as h, isStaged as i, zellijMuxAdapter as l, TMUX_WORKSPACE_GROUP_OPTION as m, resolveMux as n, currentPane as o, TMUX_TAB_NAME_OPTION as p, resolveMuxAdapter as r, probeMultiplexer as s, callerPane as t, createWeztermAdapter as u, capturedRows as v, FloatingPanesUnsupportedError as w, assertWaitPattern as x, isReadTruncated as y };
2344
3277
 
2345
- //# sourceMappingURL=backend-Cif257ji.mjs.map
3278
+ //# sourceMappingURL=backend-DjX6RlAG.mjs.map