cyber-mux 0.3.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.
- package/dist/agent.d.mts +66 -0
- package/dist/agent.d.mts.map +1 -0
- package/dist/agent.mjs +58 -0
- package/dist/agent.mjs.map +1 -0
- package/dist/{backend-DnrbmL6N.mjs → backend-DjX6RlAG.mjs} +1359 -106
- package/dist/backend-DjX6RlAG.mjs.map +1 -0
- package/dist/cli.d.mts.map +1 -1
- package/dist/cli.mjs +412 -22
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts +218 -598
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -3
- package/dist/mux-BFpEZKhM.d.mts +886 -0
- package/dist/mux-BFpEZKhM.d.mts.map +1 -0
- package/dist/template.mjs +1 -1
- package/dist/{worktree-CX88bGzZ.d.mts → worktree-9QDz-iKC.d.mts} +63 -2
- package/dist/{worktree-CX88bGzZ.d.mts.map → worktree-9QDz-iKC.d.mts.map} +1 -1
- package/dist/{worktree-CoQdRgv1.mjs → worktree-hHuFZkpW.mjs} +84 -2
- package/dist/{worktree-CoQdRgv1.mjs.map → worktree-hHuFZkpW.mjs.map} +1 -1
- package/dist/worktree.d.mts +2 -2
- package/dist/worktree.mjs +2 -2
- package/package.json +8 -3
- package/src/agent.ts +95 -0
- package/src/backend.ts +38 -8
- package/src/cli-error.ts +31 -0
- package/src/cli-options.ts +10 -1
- package/src/cli.ts +536 -62
- package/src/floating.ts +57 -0
- package/src/index.ts +7 -1
- package/src/mux-probe.ts +31 -13
- package/src/mux.cmux.ts +336 -0
- package/src/mux.herdr.ts +299 -23
- package/src/mux.otty.ts +272 -0
- package/src/mux.tmux.ts +106 -10
- package/src/mux.ts +318 -9
- package/src/mux.wezterm.ts +53 -8
- package/src/mux.zellij.ts +293 -56
- package/src/nudge.ts +11 -2
- package/src/ratio.ts +25 -0
- package/src/read-window.ts +57 -0
- package/src/template-capture.ts +66 -1
- package/src/wait-output.ts +118 -0
- package/src/worktree.ts +105 -0
- package/dist/backend-DnrbmL6N.mjs.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { h as withReason, m as nodeExec, s as normalizeWorktreePath } from "./worktree-hHuFZkpW.mjs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
//#region src/env-fallback.ts
|
|
@@ -50,6 +50,512 @@ function envFallback(env, command) {
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
//#endregion
|
|
53
|
+
//#region src/floating.ts
|
|
54
|
+
/**
|
|
55
|
+
* The floating-pane refusal — the `'pane:float'` placement's answer on a backend that has no
|
|
56
|
+
* floating-pane concept (wezterm, herdr).
|
|
57
|
+
*
|
|
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.
|
|
71
|
+
*
|
|
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.
|
|
90
|
+
*
|
|
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.
|
|
93
|
+
*/
|
|
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;
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/wait-output.ts
|
|
110
|
+
/** How long a polling backend sleeps between reads when the caller names no cadence. */
|
|
111
|
+
const DEFAULT_WAIT_POLL_MS = 150;
|
|
112
|
+
/**
|
|
113
|
+
* The seam's own precondition on a wait pattern: EXACTLY ONE of `match`/`regex`, and a `regex` that
|
|
114
|
+
* compiles.
|
|
115
|
+
*
|
|
116
|
+
* Enforced here, at the seam, rather than per adapter, for `assertRatioInRange`'s reason — it is a
|
|
117
|
+
* universal property of what a wait pattern IS, true on every backend, not a per-backend policy. Both
|
|
118
|
+
* halves matter for portability in different ways: the one-of rule is refusable by herdr's CLI and by
|
|
119
|
+
* nothing at all on a polling backend, so leaving it to the backend would make the same call fail on
|
|
120
|
+
* one and silently pick a winner on another; and compiling the source turns a MALFORMED pattern into
|
|
121
|
+
* the same loud failure everywhere, instead of a herdr refusal on one backend and a poll that throws
|
|
122
|
+
* on its first read somewhere else.
|
|
123
|
+
*
|
|
124
|
+
* What it deliberately does NOT check is dialect: a pattern using ECMAScript-only syntax compiles here
|
|
125
|
+
* and is then herdr's own to accept or refuse (see `MuxWaitOptions.regex`). Validating against the
|
|
126
|
+
* intersection of two regex engines would mean shipping a third one.
|
|
127
|
+
*/
|
|
128
|
+
function assertWaitPattern(opts) {
|
|
129
|
+
const hasMatch = opts.match != null;
|
|
130
|
+
const hasRegex = opts.regex != null;
|
|
131
|
+
if (hasMatch && hasRegex) throw new Error("wait pattern must be one of match or regex — got both");
|
|
132
|
+
if (!hasMatch && !hasRegex) throw new Error("wait pattern must be one of match or regex — got neither");
|
|
133
|
+
if (opts.match != null && opts.match === "") throw new Error("wait pattern match must not be empty");
|
|
134
|
+
if (opts.regex != null) try {
|
|
135
|
+
new RegExp(opts.regex);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new Error(`wait pattern regex is not a valid expression: ${opts.regex} — ${err.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether `output` satisfies the pattern, and the single line to point at when it does.
|
|
142
|
+
*
|
|
143
|
+
* The match runs against the WHOLE snapshot, not line by line, so a regex that spans a newline still
|
|
144
|
+
* hits — that is why `matchedLine` is derived separately and left absent when no single line carries
|
|
145
|
+
* the match on its own. Pure, so the tricky half is testable with no multiplexer at all, exactly as
|
|
146
|
+
* `template-capture`'s geometry derivation is.
|
|
147
|
+
*/
|
|
148
|
+
function matchWaitPattern(output, opts) {
|
|
149
|
+
assertWaitPattern(opts);
|
|
150
|
+
const hit = (text) => opts.match != null ? text.includes(opts.match) : new RegExp(opts.regex).test(text);
|
|
151
|
+
if (!hit(output)) return {
|
|
152
|
+
matched: false,
|
|
153
|
+
output
|
|
154
|
+
};
|
|
155
|
+
const line = output.split("\n").find(hit);
|
|
156
|
+
return {
|
|
157
|
+
matched: true,
|
|
158
|
+
output,
|
|
159
|
+
...line != null ? { matchedLine: line } : {}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `waitForOutput` for a backend with NO native wait — poll its own `read` until the pattern matches or
|
|
164
|
+
* the deadline passes. tmux, WezTerm and Zellij all route their seam method straight through here, so
|
|
165
|
+
* the three share one cadence, one deadline rule and one liveness rule rather than three copies that
|
|
166
|
+
* can drift; herdr overrides it with its native primitive.
|
|
167
|
+
*
|
|
168
|
+
* **Reads first, sleeps second.** The snapshot on screen when the call arrives is searched before any
|
|
169
|
+
* sleeping, so a pattern already printed returns immediately — the seam's stated "existing output
|
|
170
|
+
* counts" rule, and the same order herdr's native wait documents for itself.
|
|
171
|
+
*
|
|
172
|
+
* **A gone pane throws instead of timing out**, which is `nudge`'s rule for the same reason: a dead
|
|
173
|
+
* pane and a quiet one both read back empty, so without the liveness probe every dead peer would be
|
|
174
|
+
* reported as a timeout — a shape the caller reads as "still working" — and the real cause would be
|
|
175
|
+
* buried. Probed BEFORE the first read (so a pane that was already gone fails at once rather than
|
|
176
|
+
* after the full timeout) and again after the deadline (so a pane that died mid-wait is not reported as
|
|
177
|
+
* one that merely stayed quiet). Never probed per poll: that would double every backend's query load
|
|
178
|
+
* for a fact that only changes the verdict at the end.
|
|
179
|
+
*/
|
|
180
|
+
async function pollForOutput(adapter, exec, target, opts) {
|
|
181
|
+
assertWaitPattern(opts);
|
|
182
|
+
const pollMs = opts.pollMs ?? 150;
|
|
183
|
+
const now = opts.now ?? (() => Date.now());
|
|
184
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
185
|
+
const readOpts = opts.lines != null ? { lines: opts.lines } : void 0;
|
|
186
|
+
assertPaneLive(adapter, exec, target);
|
|
187
|
+
const deadline = now() + opts.timeoutMs;
|
|
188
|
+
let output = "";
|
|
189
|
+
for (;;) {
|
|
190
|
+
output = adapter.read(exec, target, readOpts).text;
|
|
191
|
+
const result = matchWaitPattern(output, opts);
|
|
192
|
+
if (result.matched) return result;
|
|
193
|
+
if (now() >= deadline) break;
|
|
194
|
+
await sleep(pollMs);
|
|
195
|
+
}
|
|
196
|
+
assertPaneLive(adapter, exec, target);
|
|
197
|
+
return {
|
|
198
|
+
matched: false,
|
|
199
|
+
output
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** The liveness probe both ends of a poll share, throwing `nudge`'s named failure rather than letting a
|
|
203
|
+
* dead pane be reported as a quiet one. */
|
|
204
|
+
function assertPaneLive(adapter, exec, target) {
|
|
205
|
+
if (!adapter.paneExists(exec, target)) throw new Error(`wait failed: pane ${target.id} no longer exists — the pane is gone, not quiet.`);
|
|
206
|
+
}
|
|
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
|
|
53
559
|
//#region src/mux.herdr.ts
|
|
54
560
|
/**
|
|
55
561
|
* herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
|
|
@@ -60,6 +566,13 @@ function envFallback(env, command) {
|
|
|
60
566
|
*
|
|
61
567
|
* The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
|
|
62
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.
|
|
63
576
|
*/
|
|
64
577
|
const herdrMuxAdapter = {
|
|
65
578
|
name: "herdr",
|
|
@@ -94,10 +607,11 @@ const herdrMuxAdapter = {
|
|
|
94
607
|
]);
|
|
95
608
|
if (!out) throw new Error(withReason(exec, "herdr tab create failed"));
|
|
96
609
|
opened = parseRootPaneId(out, "herdr tab create");
|
|
97
|
-
} else
|
|
610
|
+
} else if (at === "pane:float") refuseFloatingPane(herdrMuxAdapter.name);
|
|
611
|
+
else {
|
|
98
612
|
const direction = at === "pane:down" ? "down" : "right";
|
|
99
613
|
const from = opts.from ? [opts.from.id] : ["--current"];
|
|
100
|
-
const size = opts.ratio != null ? ["--ratio",
|
|
614
|
+
const size = opts.ratio != null ? ["--ratio", toHerdrRatio(opts.ratio)] : [];
|
|
101
615
|
const out = exec("herdr", [
|
|
102
616
|
"pane",
|
|
103
617
|
"split",
|
|
@@ -159,16 +673,86 @@ const herdrMuxAdapter = {
|
|
|
159
673
|
text
|
|
160
674
|
]);
|
|
161
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
|
+
*/
|
|
162
686
|
read(exec, target, opts) {
|
|
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
|
+
};
|
|
700
|
+
},
|
|
701
|
+
/**
|
|
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.
|
|
705
|
+
*
|
|
706
|
+
* `--source visible` is pinned rather than left to herdr's own default (`recent_unwrapped`, verified
|
|
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
|
|
708
|
+
* `read` returns, and `read` pins `visible` here; taking the default would make the same wait mean a
|
|
709
|
+
* different snapshot on this backend than on every polling one.
|
|
710
|
+
*
|
|
711
|
+
* Telling a TIMEOUT (an answer) from a broken wait (a failure) is the whole difficulty, because herdr
|
|
712
|
+
* spells both the same way: exit 1 with an error envelope on stderr, so `Exec` yields `null` for
|
|
713
|
+
* either. Two tiers answer it, in order:
|
|
714
|
+
*
|
|
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.
|
|
717
|
+
* 2. **A live pane that actually consumed the deadline**, when it did not. `Exec.lastError` is
|
|
718
|
+
* specified as a diagnostic and NEVER a control-flow signal — a runner that discards stderr must
|
|
719
|
+
* still work — so the code cannot be the only answer. Liveness alone is not enough either, and the
|
|
720
|
+
* reason is a whole released version of the backend: herdr 0.7.4 has no `pane wait-output` at all,
|
|
721
|
+
* so it answers with clap's usage text (not an envelope) INSTANTLY, and a liveness-only rule reads
|
|
722
|
+
* that as "timed out" — a silently wrong answer for a wait that never ran. Elapsed time is the fact
|
|
723
|
+
* that separates them and needs no stderr: a wait that returns in a fraction of its own timeout did
|
|
724
|
+
* not wait. Both must hold — the pane is live AND the deadline was spent — or this throws.
|
|
725
|
+
*
|
|
726
|
+
* A timeout costs ONE extra `read`, because herdr's timeout envelope carries no snapshot and the seam
|
|
727
|
+
* promises the caller the evidence its verdict was reached on. It is taken at the deadline, so it is
|
|
728
|
+
* the same "last look at the pane" a polling backend returns, one poll interval later.
|
|
729
|
+
*/
|
|
730
|
+
async waitForOutput(exec, target, opts) {
|
|
731
|
+
assertWaitPattern(opts);
|
|
732
|
+
const now = opts.now ?? (() => Date.now());
|
|
733
|
+
const pattern = opts.match != null ? ["--match", opts.match] : ["--regex", opts.regex];
|
|
163
734
|
const args = [
|
|
164
735
|
"pane",
|
|
165
|
-
"
|
|
736
|
+
"wait-output",
|
|
166
737
|
target.id,
|
|
167
738
|
"--source",
|
|
168
|
-
"visible"
|
|
739
|
+
"visible",
|
|
740
|
+
"--timeout",
|
|
741
|
+
String(opts.timeoutMs)
|
|
169
742
|
];
|
|
170
|
-
|
|
171
|
-
|
|
743
|
+
args.push(...pattern);
|
|
744
|
+
if (opts.lines != null) args.push("--lines", String(opts.lines));
|
|
745
|
+
const started = now();
|
|
746
|
+
const out = exec("herdr", args);
|
|
747
|
+
if (out == null) {
|
|
748
|
+
if (!isHerdrWaitTimeout(exec, target, opts.timeoutMs, now() - started)) throw new Error(withReason(exec, `herdr pane wait-output failed for pane ${target.id}`));
|
|
749
|
+
const readOpts = opts.lines != null ? { lines: opts.lines } : void 0;
|
|
750
|
+
return {
|
|
751
|
+
matched: false,
|
|
752
|
+
output: herdrMuxAdapter.read(exec, target, readOpts).text
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return parseWaitOutput(out);
|
|
172
756
|
},
|
|
173
757
|
focus(exec, target) {
|
|
174
758
|
const { workspaceId, tabId } = parsePaneLocation$1(exec("herdr", [
|
|
@@ -230,10 +814,13 @@ const herdrMuxAdapter = {
|
|
|
230
814
|
return panes.filter((p) => typeof p?.pane_id === "string").map((p) => {
|
|
231
815
|
const harness = p.agent || void 0;
|
|
232
816
|
const label = p.label || void 0;
|
|
817
|
+
const agentStatus = toAgentStatus(p.agent_status);
|
|
233
818
|
return {
|
|
234
819
|
id: p.pane_id,
|
|
235
820
|
mux: "herdr",
|
|
821
|
+
floating: false,
|
|
236
822
|
...harness !== void 0 ? { harness } : {},
|
|
823
|
+
...agentStatus !== void 0 ? { agentStatus } : {},
|
|
237
824
|
...p.cwd !== void 0 ? { cwd: p.cwd } : {},
|
|
238
825
|
...label !== void 0 ? { label } : {}
|
|
239
826
|
};
|
|
@@ -256,9 +843,9 @@ const herdrMuxAdapter = {
|
|
|
256
843
|
* in a DIFFERENT workspace, so nothing has to be focused first and nothing moves while this runs.
|
|
257
844
|
*
|
|
258
845
|
* herdr's own native per-tab layout export would be the obvious road — it takes a `tab_id` — but
|
|
259
|
-
* `layout` is NOT a CLI verb in 0.
|
|
260
|
-
*
|
|
261
|
-
* 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.
|
|
262
849
|
*/
|
|
263
850
|
describeWorkspace(exec, target) {
|
|
264
851
|
const { workspaceId } = parsePaneRecord(exec("herdr", [
|
|
@@ -298,9 +885,56 @@ const herdrMuxAdapter = {
|
|
|
298
885
|
if (tabs.length === 0) throw new Error(`herdr reported no usable tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
|
|
299
886
|
return tabs;
|
|
300
887
|
}
|
|
301
|
-
}
|
|
888
|
+
},
|
|
889
|
+
agentLifecycle: { waitForState(exec, target, opts) {
|
|
890
|
+
const until = opts.until ?? [];
|
|
891
|
+
const status = parseReachedAgentStatus(exec("herdr", [
|
|
892
|
+
"agent",
|
|
893
|
+
"wait",
|
|
894
|
+
target.id,
|
|
895
|
+
...until.flatMap((state) => ["--until", state]),
|
|
896
|
+
...opts.timeoutMs != null ? ["--timeout", String(opts.timeoutMs)] : []
|
|
897
|
+
]));
|
|
898
|
+
if (!status) throw new Error(withReason(exec, `herdr agent wait reported no reached agent_status for pane ${target.id}`));
|
|
899
|
+
return status;
|
|
900
|
+
} }
|
|
302
901
|
};
|
|
303
902
|
/**
|
|
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`
|
|
906
|
+
* type, so a string read off a herdr envelope can be NARROWED to it rather than cast. A value outside
|
|
907
|
+
* this set is treated as absent (the feed said something this build does not model), never forced into
|
|
908
|
+
* the type.
|
|
909
|
+
*/
|
|
910
|
+
const AGENT_STATUSES = [
|
|
911
|
+
"idle",
|
|
912
|
+
"working",
|
|
913
|
+
"blocked",
|
|
914
|
+
"done",
|
|
915
|
+
"unknown"
|
|
916
|
+
];
|
|
917
|
+
/** A value narrowed to `AgentStatus`, or `undefined` for anything else (a non-string, an empty string,
|
|
918
|
+
* or a status this build does not model) — the normalization both the listing and the wait share. */
|
|
919
|
+
function toAgentStatus(value) {
|
|
920
|
+
return typeof value === "string" && AGENT_STATUSES.includes(value) ? value : void 0;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* The `AgentStatus` a `herdr agent wait` run reached, read defensively from its JSON envelope —
|
|
924
|
+
* `{"result":{"agent":{…,"agent_status":"idle",…},"type":"agent_info"}}` (verified against 0.7.5), so
|
|
925
|
+
* the reached status lives at `.result.agent.agent_status`. Every unresolvable shape — `out` is null
|
|
926
|
+
* (an Exec failure), the JSON does not parse, or the field is missing/empty/unmodeled — folds to
|
|
927
|
+
* `undefined`, exactly as `parsePaneRecord`/`isPaneFocused` fold, so the caller states its own failure.
|
|
928
|
+
*/
|
|
929
|
+
function parseReachedAgentStatus(out) {
|
|
930
|
+
if (out == null) return void 0;
|
|
931
|
+
try {
|
|
932
|
+
return toAgentStatus(JSON.parse(out)?.result?.agent?.agent_status);
|
|
933
|
+
} catch {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
304
938
|
* The rects of the region `paneId` sits in, joined with the cwd/label half.
|
|
305
939
|
*
|
|
306
940
|
* Two sources, because herdr splits the answer across two verbs: `pane layout` reports the region's
|
|
@@ -384,13 +1018,25 @@ function herdrPaneDetails(exec, workspace) {
|
|
|
384
1018
|
*
|
|
385
1019
|
* `worktree create`/`worktree open` are deliberately NOT in that list: their params are
|
|
386
1020
|
* `[base, branch, cwd, focus, label, path, workspace_id]` and
|
|
387
|
-
* `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and
|
|
1021
|
+
* `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and herdr rejects the flag with
|
|
388
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`.
|
|
389
1025
|
*/
|
|
390
1026
|
function envFlags(env) {
|
|
391
1027
|
return env ? Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]) : [];
|
|
392
1028
|
}
|
|
393
1029
|
/**
|
|
1030
|
+
* `--ratio` takes the seam's number VERBATIM — herdr sizes the ORIGINAL pane, so no inversion, unlike
|
|
1031
|
+
* tmux's `-l` and wezterm's `--percent`. The guard is the same one those two render helpers call: the
|
|
1032
|
+
* seam refuses an out-of-range ratio here rather than pass `--ratio 5` (or `0`) through to a split herdr
|
|
1033
|
+
* would then size wrong.
|
|
1034
|
+
*/
|
|
1035
|
+
function toHerdrRatio(ratio) {
|
|
1036
|
+
assertRatioInRange(ratio);
|
|
1037
|
+
return String(ratio);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
394
1040
|
* Launch a command in a worktree's root pane, carrying env the worktree verb could not set at birth.
|
|
395
1041
|
* The prefix-or-warn rule is the seam's (`env-fallback.ts`); this is the one route that invokes it,
|
|
396
1042
|
* because it is the one route that loses env. With a command, env rides in as a prefix; with none and
|
|
@@ -437,6 +1083,65 @@ function nonEmpty(value) {
|
|
|
437
1083
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
438
1084
|
}
|
|
439
1085
|
/**
|
|
1086
|
+
* The `code` of a herdr error envelope, when the runner captured one — how a wait's TIMEOUT (an answer)
|
|
1087
|
+
* is told from any other failure (a throw). Read from `Exec.lastError` because that is where herdr's
|
|
1088
|
+
* envelope lands: it is written to stderr with exit 1, so stdout is `null` for every failure alike and
|
|
1089
|
+
* the code is the only thing that separates them. Defensive throughout — no reason, unparseable JSON, or
|
|
1090
|
+
* a missing/non-string code all answer `undefined`, which routes to the throw rather than to a silent
|
|
1091
|
+
* "timed out" the backend never said.
|
|
1092
|
+
*/
|
|
1093
|
+
/**
|
|
1094
|
+
* How much of its own timeout a wait must actually spend before a failure is believed to BE that
|
|
1095
|
+
* timeout. A fraction rather than the whole, because process start-up and clock granularity make an
|
|
1096
|
+
* exact-or-greater comparison flaky on a real runner; wide enough that the case it exists to catch — a
|
|
1097
|
+
* herdr with no `wait-output` subcommand, which returns in milliseconds — is nowhere near it.
|
|
1098
|
+
*/
|
|
1099
|
+
const HERDR_WAIT_ELAPSED_RATIO = .9;
|
|
1100
|
+
/**
|
|
1101
|
+
* Whether a failed `pane wait-output` was the DEADLINE passing rather than the wait breaking — the
|
|
1102
|
+
* two-tier rule `waitForOutput` documents, kept out of the method so the tiers read as one decision.
|
|
1103
|
+
*
|
|
1104
|
+
* The envelope's code answers when the runner captured one. Otherwise the answer needs two facts, and
|
|
1105
|
+
* neither alone is enough: the pane must be LIVE (a gone pane is a failure, `pollForOutput`'s rule) and
|
|
1106
|
+
* the call must have SPENT the deadline (a wait that returned instantly never ran — herdr 0.7.4, whose
|
|
1107
|
+
* usage text for an unknown subcommand is not an envelope to read a code from).
|
|
1108
|
+
*/
|
|
1109
|
+
function isHerdrWaitTimeout(exec, target, timeoutMs, elapsedMs) {
|
|
1110
|
+
const code = herdrErrorCode(exec.lastError);
|
|
1111
|
+
if (code != null) return code === "timeout";
|
|
1112
|
+
if (elapsedMs < timeoutMs * HERDR_WAIT_ELAPSED_RATIO) return false;
|
|
1113
|
+
return herdrMuxAdapter.paneExists(exec, target);
|
|
1114
|
+
}
|
|
1115
|
+
function herdrErrorCode(reason) {
|
|
1116
|
+
if (!reason) return void 0;
|
|
1117
|
+
try {
|
|
1118
|
+
return nonEmpty(JSON.parse(reason)?.error?.code);
|
|
1119
|
+
} catch {
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* A successful `pane wait-output` envelope: the snapshot it matched in, and the line it matched on.
|
|
1125
|
+
*
|
|
1126
|
+
* `matched` is `true` by construction — herdr exits 0 only on a match, so reaching here IS the match;
|
|
1127
|
+
* the parse only fills in the evidence. Defensive for the same reason `parsePaneRecord` is: a herdr
|
|
1128
|
+
* build that reshapes the envelope degrades to a match with no snapshot, never to a failed wait.
|
|
1129
|
+
*/
|
|
1130
|
+
function parseWaitOutput(out) {
|
|
1131
|
+
let text;
|
|
1132
|
+
let line;
|
|
1133
|
+
try {
|
|
1134
|
+
const result = JSON.parse(out)?.result;
|
|
1135
|
+
text = nonEmpty(result?.read?.text);
|
|
1136
|
+
line = nonEmpty(result?.matched_line);
|
|
1137
|
+
} catch {}
|
|
1138
|
+
return {
|
|
1139
|
+
matched: true,
|
|
1140
|
+
output: text ?? "",
|
|
1141
|
+
...line != null ? { matchedLine: line } : {}
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
440
1145
|
* The pane's workspace and tab, or a throw — so `focus` never issues a workspace/tab switch against a
|
|
441
1146
|
* pane it couldn't actually resolve.
|
|
442
1147
|
*/
|
|
@@ -526,8 +1231,8 @@ function parseRootPaneId(out, label) {
|
|
|
526
1231
|
/**
|
|
527
1232
|
* Every pane herdr emits carries its own `workspace_id` alongside its `pane_id`, on EVERY route —
|
|
528
1233
|
* `workspace create` (which reports the workspace it just made), `tab create` (the workspace the tab
|
|
529
|
-
* was created in), and `pane split` (the workspace the split landed in, i.e. the caller's).
|
|
530
|
-
* against herdr 0.
|
|
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
|
|
531
1236
|
* the pane id is already read from, so probing for it separately would buy nothing and cost a round
|
|
532
1237
|
* trip per open.
|
|
533
1238
|
*
|
|
@@ -618,6 +1323,270 @@ function parseWorktreeBindings(out) {
|
|
|
618
1323
|
}
|
|
619
1324
|
return bindings;
|
|
620
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
|
+
}
|
|
621
1590
|
//#endregion
|
|
622
1591
|
//#region src/mux.tmux.ts
|
|
623
1592
|
/**
|
|
@@ -651,6 +1620,25 @@ const TMUX_TAB_NAME_OPTION = "@cm_tab";
|
|
|
651
1620
|
const tmuxMuxAdapter = {
|
|
652
1621
|
name: "tmux",
|
|
653
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,
|
|
654
1642
|
open(exec, opts) {
|
|
655
1643
|
const at = opts.at ?? "tab";
|
|
656
1644
|
const window = at === "workspace" || at === "tab";
|
|
@@ -658,7 +1646,17 @@ const tmuxMuxAdapter = {
|
|
|
658
1646
|
const group = window && opts.workspaceGroup != null;
|
|
659
1647
|
const format = "#{pane_id} #{window_id}";
|
|
660
1648
|
let args;
|
|
661
|
-
if (
|
|
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 = [
|
|
662
1660
|
"new-window",
|
|
663
1661
|
"-d",
|
|
664
1662
|
...env,
|
|
@@ -769,14 +1767,19 @@ const tmuxMuxAdapter = {
|
|
|
769
1767
|
]);
|
|
770
1768
|
},
|
|
771
1769
|
read(exec, target, opts) {
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
+
};
|
|
1780
|
+
},
|
|
1781
|
+
waitForOutput(exec, target, opts) {
|
|
1782
|
+
return pollForOutput(tmuxMuxAdapter, exec, target, opts);
|
|
780
1783
|
},
|
|
781
1784
|
focus(exec, target) {
|
|
782
1785
|
const { sessionName, windowId } = parsePaneLocation(exec("tmux", [
|
|
@@ -847,14 +1850,15 @@ const tmuxMuxAdapter = {
|
|
|
847
1850
|
"list-panes",
|
|
848
1851
|
"-a",
|
|
849
1852
|
"-F",
|
|
850
|
-
"#{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}"
|
|
851
1854
|
]);
|
|
852
1855
|
if (!out) return [];
|
|
853
1856
|
return out.split("\n").filter(Boolean).map((line) => {
|
|
854
|
-
const [id, , cwd, title, host] = line.split(" ");
|
|
1857
|
+
const [id, , cwd, title, host, floating] = line.split(" ");
|
|
855
1858
|
const pane = {
|
|
856
1859
|
id: id ?? "",
|
|
857
|
-
mux: "tmux"
|
|
1860
|
+
mux: "tmux",
|
|
1861
|
+
floating: floating === "1"
|
|
858
1862
|
};
|
|
859
1863
|
if (cwd) pane.cwd = cwd;
|
|
860
1864
|
const label = paneLabel(title, host);
|
|
@@ -1018,6 +2022,7 @@ function splitOpenReport(out, command) {
|
|
|
1018
2022
|
* the same thing without first querying the region's size.
|
|
1019
2023
|
*/
|
|
1020
2024
|
function toTmuxSize(ratio) {
|
|
2025
|
+
assertRatioInRange(ratio);
|
|
1021
2026
|
return `${Math.round((1 - ratio) * 100)}%`;
|
|
1022
2027
|
}
|
|
1023
2028
|
/**
|
|
@@ -1032,6 +2037,22 @@ function toTmuxSize(ratio) {
|
|
|
1032
2037
|
* key list here would make the passthrough a second vocabulary to maintain.
|
|
1033
2038
|
*/
|
|
1034
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
|
+
}
|
|
1035
2056
|
function toTmuxKey(key) {
|
|
1036
2057
|
return TMUX_KEY_RENAMES[key] ?? key;
|
|
1037
2058
|
}
|
|
@@ -1139,6 +2160,7 @@ function createWeztermAdapter(deps) {
|
|
|
1139
2160
|
runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
|
|
1140
2161
|
return opened;
|
|
1141
2162
|
}
|
|
2163
|
+
if (at === "pane:float") refuseFloatingPane(adapter.name);
|
|
1142
2164
|
const direction = at === "pane:down" ? ["--bottom"] : ["--right"];
|
|
1143
2165
|
const from = opts.from ? ["--pane-id", opts.from.id] : [];
|
|
1144
2166
|
const size = opts.ratio != null ? ["--percent", toWeztermSize(opts.ratio)] : [];
|
|
@@ -1202,14 +2224,19 @@ function createWeztermAdapter(deps) {
|
|
|
1202
2224
|
adapter.sendKeys(exec, target, ["Enter"]);
|
|
1203
2225
|
},
|
|
1204
2226
|
read(exec, target, opts) {
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
+
};
|
|
2237
|
+
},
|
|
2238
|
+
waitForOutput(exec, target, opts) {
|
|
2239
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
1213
2240
|
},
|
|
1214
2241
|
focus(exec, target) {
|
|
1215
2242
|
exec("wezterm", [
|
|
@@ -1235,7 +2262,8 @@ function createWeztermAdapter(deps) {
|
|
|
1235
2262
|
return listWeztermPanes(exec).map((p) => {
|
|
1236
2263
|
const pane = {
|
|
1237
2264
|
id: String(p.pane_id),
|
|
1238
|
-
mux: "wezterm"
|
|
2265
|
+
mux: "wezterm",
|
|
2266
|
+
floating: false
|
|
1239
2267
|
};
|
|
1240
2268
|
const cwd = weztermCwd(p.cwd);
|
|
1241
2269
|
if (cwd) pane.cwd = cwd;
|
|
@@ -1326,6 +2354,7 @@ function weztermCwd(cwd) {
|
|
|
1326
2354
|
* probe note, #47) — the same inversion tmux's `-l` needs, unlike herdr's pass-through.
|
|
1327
2355
|
*/
|
|
1328
2356
|
function toWeztermSize(ratio) {
|
|
2357
|
+
assertRatioInRange(ratio);
|
|
1329
2358
|
return String(Math.round((1 - ratio) * 100));
|
|
1330
2359
|
}
|
|
1331
2360
|
/**
|
|
@@ -1370,6 +2399,26 @@ const WEZTERM_KEY_BYTES = {
|
|
|
1370
2399
|
PageUp: "\x1B[5~",
|
|
1371
2400
|
PageDown: "\x1B[6~"
|
|
1372
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
|
+
}
|
|
1373
2422
|
//#endregion
|
|
1374
2423
|
//#region src/mux.zellij.ts
|
|
1375
2424
|
/**
|
|
@@ -1384,13 +2433,40 @@ const WEZTERM_KEY_BYTES = {
|
|
|
1384
2433
|
* binary these commands fail and the adapter surfaces the failure rather than silently mis-targeting
|
|
1385
2434
|
* the focused pane.
|
|
1386
2435
|
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
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.
|
|
1394
2470
|
*
|
|
1395
2471
|
* Real capability shape that fell out of the probe:
|
|
1396
2472
|
*
|
|
@@ -1439,6 +2515,7 @@ const WEZTERM_KEY_BYTES = {
|
|
|
1439
2515
|
function createZellijAdapter(deps) {
|
|
1440
2516
|
const adapter = {
|
|
1441
2517
|
name: "zellij",
|
|
2518
|
+
canFloatPanes: true,
|
|
1442
2519
|
open(exec, opts) {
|
|
1443
2520
|
const at = opts.at ?? "tab";
|
|
1444
2521
|
if (at === "tab" || at === "workspace") {
|
|
@@ -1449,11 +2526,10 @@ function createZellijAdapter(deps) {
|
|
|
1449
2526
|
opts.cwd
|
|
1450
2527
|
];
|
|
1451
2528
|
if (opts.label) args.push("--name", opts.label);
|
|
2529
|
+
const before = paneIdSet(exec);
|
|
1452
2530
|
const out = exec("zellij", args);
|
|
1453
|
-
if (
|
|
1454
|
-
const
|
|
1455
|
-
if (!tabId) throw new Error("zellij action new-tab did not report the new tab id");
|
|
1456
|
-
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);
|
|
1457
2533
|
runLaunch(adapter, exec, opened, opts.env, opts.launch);
|
|
1458
2534
|
return opened;
|
|
1459
2535
|
}
|
|
@@ -1461,17 +2537,15 @@ function createZellijAdapter(deps) {
|
|
|
1461
2537
|
const args = [
|
|
1462
2538
|
"action",
|
|
1463
2539
|
"new-pane",
|
|
1464
|
-
"--direction",
|
|
1465
|
-
at === "pane:down" ? "down" : "right",
|
|
2540
|
+
...at === "pane:float" ? ["--floating"] : ["--direction", at === "pane:down" ? "down" : "right"],
|
|
1466
2541
|
"--cwd",
|
|
1467
2542
|
opts.cwd
|
|
1468
2543
|
];
|
|
1469
2544
|
if (opts.label) args.push("--name", opts.label);
|
|
2545
|
+
const before = paneIdSet(exec);
|
|
1470
2546
|
const out = exec("zellij", args);
|
|
1471
|
-
if (
|
|
1472
|
-
const
|
|
1473
|
-
if (!paneId) throw new Error("zellij action new-pane did not report the new pane id");
|
|
1474
|
-
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);
|
|
1475
2549
|
runLaunch(adapter, exec, opened, opts.env, opts.launch);
|
|
1476
2550
|
return opened;
|
|
1477
2551
|
},
|
|
@@ -1521,19 +2595,53 @@ function createZellijAdapter(deps) {
|
|
|
1521
2595
|
adapter.sendKeys(exec, target, ["Enter"]);
|
|
1522
2596
|
},
|
|
1523
2597
|
read(exec, target, opts) {
|
|
1524
|
-
if (opts?.lines
|
|
1525
|
-
"
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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", [
|
|
1532
2626
|
"action",
|
|
1533
2627
|
"dump-screen",
|
|
1534
2628
|
"--pane-id",
|
|
1535
2629
|
target.id
|
|
1536
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
|
+
};
|
|
2642
|
+
},
|
|
2643
|
+
waitForOutput(exec, target, opts) {
|
|
2644
|
+
return pollForOutput(adapter, exec, target, opts);
|
|
1537
2645
|
},
|
|
1538
2646
|
focus(exec, target) {
|
|
1539
2647
|
exec("zellij", [
|
|
@@ -1562,10 +2670,11 @@ function createZellijAdapter(deps) {
|
|
|
1562
2670
|
return listZellijPanes(exec).map((p) => {
|
|
1563
2671
|
const pane = {
|
|
1564
2672
|
id: p.id,
|
|
1565
|
-
mux: "zellij"
|
|
2673
|
+
mux: "zellij",
|
|
2674
|
+
floating: p.is_floating === true
|
|
1566
2675
|
};
|
|
1567
2676
|
if (p.pane_cwd) pane.cwd = p.pane_cwd;
|
|
1568
|
-
const label = zellijLabel(p.title, p.
|
|
2677
|
+
const label = zellijLabel(p.title, p.terminal_command);
|
|
1569
2678
|
if (label) pane.label = label;
|
|
1570
2679
|
return pane;
|
|
1571
2680
|
});
|
|
@@ -1575,49 +2684,158 @@ function createZellijAdapter(deps) {
|
|
|
1575
2684
|
}
|
|
1576
2685
|
const zellijMuxAdapter = createZellijAdapter({});
|
|
1577
2686
|
/**
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
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.
|
|
2712
|
+
*/
|
|
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.
|
|
1581
2720
|
*/
|
|
1582
|
-
function
|
|
2721
|
+
function readZellijPanes(exec) {
|
|
1583
2722
|
const out = exec("zellij", [
|
|
1584
2723
|
"action",
|
|
1585
2724
|
"list-panes",
|
|
1586
2725
|
"--json"
|
|
1587
2726
|
]);
|
|
1588
|
-
if (!out) return
|
|
2727
|
+
if (!out) return void 0;
|
|
1589
2728
|
let parsed;
|
|
1590
2729
|
try {
|
|
1591
2730
|
parsed = JSON.parse(out);
|
|
1592
2731
|
} catch {
|
|
1593
|
-
return
|
|
2732
|
+
return;
|
|
1594
2733
|
}
|
|
1595
|
-
if (!Array.isArray(parsed)) return
|
|
2734
|
+
if (!Array.isArray(parsed)) return void 0;
|
|
1596
2735
|
return parsed.filter((p) => p != null && p.id != null).map((p) => ({
|
|
1597
2736
|
...p,
|
|
1598
|
-
id:
|
|
2737
|
+
id: zellijPaneId(p.id, p.is_plugin)
|
|
1599
2738
|
}));
|
|
1600
2739
|
}
|
|
1601
2740
|
/**
|
|
1602
|
-
* The
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
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.
|
|
1606
2755
|
*/
|
|
1607
|
-
function
|
|
1608
|
-
const
|
|
1609
|
-
if (
|
|
1610
|
-
return
|
|
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}`;
|
|
1611
2760
|
}
|
|
1612
2761
|
/**
|
|
1613
|
-
* The
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
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.
|
|
1616
2766
|
*/
|
|
1617
|
-
function
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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 [];
|
|
2773
|
+
}
|
|
2774
|
+
/**
|
|
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.
|
|
2791
|
+
*/
|
|
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");
|
|
1621
2839
|
}
|
|
1622
2840
|
/**
|
|
1623
2841
|
* Assemble an `OpenedPane`, attaching `workspace` only when the ambient session name is known — the
|
|
@@ -1668,7 +2886,7 @@ function toZellijKey(key) {
|
|
|
1668
2886
|
}
|
|
1669
2887
|
/**
|
|
1670
2888
|
* A Zellij pane's label — its title, unless that title is the running command Zellij handed an unnamed
|
|
1671
|
-
* pane. Zellij defaults an unnamed pane's title to its `
|
|
2889
|
+
* pane. Zellij defaults an unnamed pane's title to its `terminal_command`, so a title equal to it is
|
|
1672
2890
|
* ambient rather than chosen; exporting it would put the same manufactured name on every shell pane,
|
|
1673
2891
|
* exactly the collision tmux's hostname guard exists to prevent. A title that differs from the command
|
|
1674
2892
|
* is one someone set (`new-pane --name`/`rename-pane`), so it is the author's and survives.
|
|
@@ -1701,6 +2919,8 @@ const KNOWN_MUX = [
|
|
|
1701
2919
|
"herdr",
|
|
1702
2920
|
"wezterm",
|
|
1703
2921
|
"zellij",
|
|
2922
|
+
"cmux",
|
|
2923
|
+
"otty",
|
|
1704
2924
|
"screen",
|
|
1705
2925
|
"none"
|
|
1706
2926
|
];
|
|
@@ -1713,25 +2933,29 @@ function isKnownMux(v) {
|
|
|
1713
2933
|
* every pane (its own bare-integer id) — per the issue that requested that backend (#47), the same
|
|
1714
2934
|
* fast-path extension `$TMUX_PANE`/`$HERDR_PANE_ID` already get; Zellij exports `$ZELLIJ_PANE_ID` in
|
|
1715
2935
|
* every terminal pane (its own `terminal_N`/bare-`N` id) — per the issue that requested this backend
|
|
1716
|
-
* (#46)
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
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.
|
|
1719
2940
|
*/
|
|
1720
2941
|
const PANE_ENV = {
|
|
1721
2942
|
tmux: (env) => env["TMUX_PANE"],
|
|
1722
2943
|
herdr: (env) => env["HERDR_PANE_ID"],
|
|
1723
2944
|
wezterm: (env) => env["WEZTERM_PANE"],
|
|
1724
|
-
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"]
|
|
1725
2948
|
};
|
|
1726
2949
|
/**
|
|
1727
2950
|
* Resolve THIS session's own pane from env alone (no `ps` walk): the `$CYBER_MUX_PANE` fast-path a
|
|
1728
2951
|
* spawn propagates → `$TMUX_PANE` (tmux) → `$HERDR_PANE_ID` (herdr) → `$WEZTERM_PANE` (wezterm) →
|
|
1729
|
-
* `$ZELLIJ_PANE_ID` (zellij)
|
|
1730
|
-
* session is in no pane-carrying multiplexer.
|
|
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.
|
|
1731
2955
|
*/
|
|
1732
2956
|
function currentPane(env) {
|
|
1733
2957
|
if (env["CYBER_MUX_PANE"]) return {
|
|
1734
|
-
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",
|
|
1735
2959
|
pane: env["CYBER_MUX_PANE"]
|
|
1736
2960
|
};
|
|
1737
2961
|
const tmux = PANE_ENV.tmux(env);
|
|
@@ -1754,6 +2978,16 @@ function currentPane(env) {
|
|
|
1754
2978
|
mux: "zellij",
|
|
1755
2979
|
pane: zellij
|
|
1756
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
|
+
};
|
|
1757
2991
|
}
|
|
1758
2992
|
/**
|
|
1759
2993
|
* Two-mode multiplexer detection.
|
|
@@ -1809,7 +3043,7 @@ const MUX_COMM = [
|
|
|
1809
3043
|
];
|
|
1810
3044
|
/** The per-pane env var for a mux, via the shared `PANE_ENV` table; undefined for screen/none. */
|
|
1811
3045
|
function paneFor(mux, env) {
|
|
1812
|
-
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;
|
|
1813
3047
|
}
|
|
1814
3048
|
/**
|
|
1815
3049
|
* An ancestry-discovered probe, OMITTING `pane` when the mux carries none — never carrying it as an
|
|
@@ -1855,6 +3089,8 @@ function discoverByAncestry(exec, env) {
|
|
|
1855
3089
|
if (env["HERDR_ENV"]) return ancestryProbe("herdr", env);
|
|
1856
3090
|
if (env["WEZTERM_PANE"]) return ancestryProbe("wezterm", env);
|
|
1857
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);
|
|
1858
3094
|
return {
|
|
1859
3095
|
mux: "none",
|
|
1860
3096
|
via: "ancestry"
|
|
@@ -1891,6 +3127,15 @@ function isStaged(visible, message) {
|
|
|
1891
3127
|
* A pane that no longer exists is rejected up front rather than retried: a gone pane and a booting
|
|
1892
3128
|
* one both read back empty, so without the liveness probe the retry loop reports a dead peer as
|
|
1893
3129
|
* "never took the turn" — a boot-race shape — and buries the real cause.
|
|
3130
|
+
*
|
|
3131
|
+
* **Not built on `waitForOutput`, deliberately.** The two look alike and wait on opposite conditions:
|
|
3132
|
+
* `waitForOutput` returns when a pattern APPEARS anywhere in the snapshot, while nudge returns when the
|
|
3133
|
+
* message DISAPPEARS from the input box at the bottom (`isStaged`) — a negative, position-sensitive
|
|
3134
|
+
* condition the wait primitive cannot express, and one that must not be satisfied by the same text
|
|
3135
|
+
* sitting up in the transcript, which is exactly where a submitted message ends up. Nor is the loop body
|
|
3136
|
+
* the same: nudge does not merely observe between polls, it re-submits, so its "poll" is a corrective
|
|
3137
|
+
* action with its own attempt budget rather than a read. What the two DO share is the liveness rule —
|
|
3138
|
+
* a gone pane throws instead of being reported as a quiet one — and `pollForOutput` adopts it from here.
|
|
1894
3139
|
*/
|
|
1895
3140
|
async function nudge(adapter, exec, target, message, opts = {}) {
|
|
1896
3141
|
const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
|
|
@@ -1899,14 +3144,14 @@ async function nudge(adapter, exec, target, message, opts = {}) {
|
|
|
1899
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.`);
|
|
1900
3145
|
adapter.submit(exec, target, message);
|
|
1901
3146
|
await sleep(settleMs);
|
|
1902
|
-
if (!isStaged(adapter.read(exec, target), message)) return {
|
|
3147
|
+
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
1903
3148
|
taken: true,
|
|
1904
3149
|
resubmits: 0
|
|
1905
3150
|
};
|
|
1906
3151
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
1907
3152
|
adapter.submit(exec, target);
|
|
1908
3153
|
await sleep(settleMs);
|
|
1909
|
-
if (!isStaged(adapter.read(exec, target), message)) return {
|
|
3154
|
+
if (!isStaged(adapter.read(exec, target).text, message)) return {
|
|
1910
3155
|
taken: true,
|
|
1911
3156
|
resubmits: attempt
|
|
1912
3157
|
};
|
|
@@ -1918,14 +3163,15 @@ async function nudge(adapter, exec, target, message, opts = {}) {
|
|
|
1918
3163
|
/**
|
|
1919
3164
|
* Resolve the raw backend for the multiplexer this process is inside, via the two-mode mux probe
|
|
1920
3165
|
* (`$CYBER_MUX` fast-path/override, else ancestry discovery from `$$` falling back to the
|
|
1921
|
-
* `$TMUX`/`$HERDR_ENV`/`$WEZTERM_PANE`/`$ZELLIJ` hint when the walk is
|
|
1922
|
-
* tmux/herdr/wezterm/zellij map to their adapters; anything else throws,
|
|
1923
|
-
* drive panes with no multiplexer has an unmet precondition, and a loud,
|
|
1924
|
-
* 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.
|
|
1925
3170
|
*
|
|
1926
3171
|
* The zellij adapter is bound to the ambient session name (`$ZELLIJ_SESSION_NAME`) at resolution — it
|
|
1927
3172
|
* reports that as `OpenedPane.workspace`, so the session name has to come from the SAME `env` the
|
|
1928
|
-
* 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`).
|
|
1929
3175
|
*
|
|
1930
3176
|
* `screen` is a KNOWN mux — the probe detects it (ancestry) and honors it as an override (fast-path)
|
|
1931
3177
|
* — but it is NOT a drivable backend, so it throws its OWN message naming the reason rather than the
|
|
@@ -1951,8 +3197,10 @@ function resolveMuxAdapter(env, exec = nodeExec) {
|
|
|
1951
3197
|
if (probe.mux === "herdr") return herdrMuxAdapter;
|
|
1952
3198
|
if (probe.mux === "wezterm") return weztermMuxAdapter;
|
|
1953
3199
|
if (probe.mux === "zellij") return createZellijAdapter({ session: env["ZELLIJ_SESSION_NAME"] });
|
|
1954
|
-
if (probe.mux === "
|
|
1955
|
-
|
|
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)");
|
|
1956
3204
|
}
|
|
1957
3205
|
/**
|
|
1958
3206
|
* Resolve the multiplexer this process is inside and return it as a `MuxSession` with `exec` BOUND —
|
|
@@ -1978,6 +3226,7 @@ function resolveMux(env, deps) {
|
|
|
1978
3226
|
sendKeys: (target, keys, d) => raw.sendKeys(pick(d), target, keys),
|
|
1979
3227
|
submit: (target, text, d) => raw.submit(pick(d), target, text),
|
|
1980
3228
|
read: (target, opts, d) => raw.read(pick(d), target, opts),
|
|
3229
|
+
waitForOutput: (target, opts, d) => raw.waitForOutput(pick(d), target, opts),
|
|
1981
3230
|
focus: (target, d) => raw.focus(pick(d), target),
|
|
1982
3231
|
teardown: (target, d) => raw.teardown(pick(d), target),
|
|
1983
3232
|
paneExists: (target, d) => raw.paneExists(pick(d), target),
|
|
@@ -1985,9 +3234,13 @@ function resolveMux(env, deps) {
|
|
|
1985
3234
|
listPanes: (d) => raw.listPanes(pick(d)),
|
|
1986
3235
|
nudge: (target, message, opts, d) => nudge(raw, pick(d), target, message, opts),
|
|
1987
3236
|
...raw.worktree ? { worktree: bindWorktree(raw.worktree, pick) } : {},
|
|
1988
|
-
...raw.regions ? { regions: bindRegions(raw.regions, pick) } : {}
|
|
3237
|
+
...raw.regions ? { regions: bindRegions(raw.regions, pick) } : {},
|
|
3238
|
+
...raw.agentLifecycle ? { agentLifecycle: bindAgentLifecycle(raw.agentLifecycle, pick) } : {}
|
|
1989
3239
|
};
|
|
1990
3240
|
}
|
|
3241
|
+
function bindAgentLifecycle(agent, pick) {
|
|
3242
|
+
return { waitForState: (target, opts, d) => agent.waitForState(pick(d), target, opts) };
|
|
3243
|
+
}
|
|
1991
3244
|
function bindWorktree(wt, pick) {
|
|
1992
3245
|
return {
|
|
1993
3246
|
createInWorkspace: (opts, d) => wt.createInWorkspace(pick(d), opts),
|
|
@@ -2020,6 +3273,6 @@ function callerPane(adapter, env) {
|
|
|
2020
3273
|
return self && self.mux === adapter.name ? { id: self.pane } : void 0;
|
|
2021
3274
|
}
|
|
2022
3275
|
//#endregion
|
|
2023
|
-
export { envFallback as _, nudge as a, 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 };
|
|
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 };
|
|
2024
3277
|
|
|
2025
|
-
//# sourceMappingURL=backend-
|
|
3278
|
+
//# sourceMappingURL=backend-DjX6RlAG.mjs.map
|