cyber-mux 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. package/bin/cyber-mux.mjs +0 -0
  2. package/dist/backend-DnrbmL6N.mjs +2025 -0
  3. package/dist/backend-DnrbmL6N.mjs.map +1 -0
  4. package/dist/cli.d.mts +64 -0
  5. package/dist/cli.d.mts.map +1 -0
  6. package/dist/cli.mjs +641 -2058
  7. package/dist/cli.mjs.map +1 -0
  8. package/dist/exec-B81m4yjz.d.mts +39 -0
  9. package/dist/exec-B81m4yjz.d.mts.map +1 -0
  10. package/dist/index.d.mts +953 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3 -0
  13. package/dist/template-store-LQfmvcGT.d.mts +118 -0
  14. package/dist/template-store-LQfmvcGT.d.mts.map +1 -0
  15. package/dist/template.d.mts +131 -0
  16. package/dist/template.d.mts.map +1 -0
  17. package/dist/template.mjs +374 -0
  18. package/dist/template.mjs.map +1 -0
  19. package/dist/worktree-CX88bGzZ.d.mts +228 -0
  20. package/dist/worktree-CX88bGzZ.d.mts.map +1 -0
  21. package/dist/worktree-CoQdRgv1.mjs +329 -0
  22. package/dist/worktree-CoQdRgv1.mjs.map +1 -0
  23. package/dist/worktree.d.mts +2 -0
  24. package/dist/worktree.mjs +2 -0
  25. package/package.json +71 -57
  26. package/src/backend.ts +210 -0
  27. package/src/cli-error.ts +100 -0
  28. package/src/cli-options.ts +44 -0
  29. package/src/cli.ts +1669 -0
  30. package/src/env-fallback.ts +51 -0
  31. package/src/exec.ts +53 -0
  32. package/src/index.ts +20 -0
  33. package/src/mux-probe.ts +181 -0
  34. package/src/mux.herdr.ts +657 -0
  35. package/src/mux.tmux.ts +476 -0
  36. package/src/mux.ts +594 -0
  37. package/src/mux.wezterm.ts +361 -0
  38. package/src/mux.zellij.ts +363 -0
  39. package/src/new-id.ts +15 -0
  40. package/src/nudge.ts +85 -0
  41. package/src/output.ts +96 -0
  42. package/src/prompt.ts +68 -0
  43. package/src/template-capture.ts +320 -0
  44. package/src/template-edit.ts +350 -0
  45. package/src/template-session.ts +690 -0
  46. package/src/template-store.ts +216 -0
  47. package/src/template.ts +429 -0
  48. package/src/worktree-session.ts +208 -0
  49. package/src/worktree.ts +451 -0
@@ -0,0 +1,2025 @@
1
+ import { m as withReason, p as nodeExec, s as normalizeWorktreePath } from "./worktree-CoQdRgv1.mjs";
2
+ import { resolve } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ //#region src/env-fallback.ts
5
+ /**
6
+ * The env-prefix fallback — the one compensation for a route that could not set env at birth.
7
+ *
8
+ * env is native at every tier on both backends EXCEPT herdr's worktree `create`/`open`, which take
9
+ * no env parameter (0.7.4 answers `--env` with `unknown option`). A route that hit that wall carries
10
+ * env the only way left: as an `env KEY=VALUE` prefix on the command the pane runs. It is a LAST
11
+ * resort — the values land in `ps` output and the pane's shell history — and it only works when there
12
+ * IS a command to ride; with none, the honest outcome is to warn, never to drop silently.
13
+ *
14
+ * This lives in one module, called by both routes that can lose env (the CLI worktree verbs and the
15
+ * template walk's root pane), so the rule cannot be wired on one and forgotten on the other. Only a
16
+ * route that lost env may call it: prefixing over a natively-set env would push the values into `ps`
17
+ * and shell history on every route, the exact cost the prefix exists to pay only when it must.
18
+ */
19
+ /**
20
+ * Single-quote a value for a shell command line. Everything is literal inside single quotes, so the
21
+ * only escape needed is for a single quote itself: end the quoting, emit an escaped `'`, reopen.
22
+ * Without this a value carrying a space or a quote would split into extra words, or unbalance the
23
+ * line outright.
24
+ */
25
+ function shellQuote(value) {
26
+ return `'${value.replace(/'/g, `'\\''`)}'`;
27
+ }
28
+ /** `env K=V …` with a trailing space, ready to prepend to a command line. Values are shell-quoted. */
29
+ function envPrefix(env) {
30
+ return `env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `;
31
+ }
32
+ /**
33
+ * Given the env a route could not carry and the command (if any) that would run in the opened pane,
34
+ * decide how env rides in. With a command, env is prefixed onto it and the pane carries the value;
35
+ * with none, env is dropped and the caller warns. No env (or an empty map) is `carried` unchanged, so
36
+ * a caller on the losing route can call this unconditionally and get the right command back.
37
+ */
38
+ function envFallback(env, command) {
39
+ if (env === void 0 || Object.keys(env).length === 0) return {
40
+ kind: "carried",
41
+ command
42
+ };
43
+ if (command === void 0) return {
44
+ kind: "dropped",
45
+ variables: Object.keys(env)
46
+ };
47
+ return {
48
+ kind: "carried",
49
+ command: `${envPrefix(env)}${command}`
50
+ };
51
+ }
52
+ //#endregion
53
+ //#region src/mux.herdr.ts
54
+ /**
55
+ * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
56
+ * multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
57
+ * only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
58
+ * rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
59
+ * convention exactly like the tmux adapter — no new client/transport needed.
60
+ *
61
+ * The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
62
+ * returns a JSON `pane_info` envelope whose id is extracted in `parsePaneId`.
63
+ */
64
+ const herdrMuxAdapter = {
65
+ name: "herdr",
66
+ canSizeSplits: true,
67
+ open(exec, opts) {
68
+ const at = opts.at ?? "tab";
69
+ const label = opts.label ? ["--label", opts.label] : [];
70
+ const env = envFlags(opts.env);
71
+ let opened;
72
+ if (at === "workspace") {
73
+ const out = exec("herdr", [
74
+ "workspace",
75
+ "create",
76
+ "--cwd",
77
+ opts.cwd,
78
+ ...label,
79
+ ...env,
80
+ "--no-focus"
81
+ ]);
82
+ if (!out) throw new Error(withReason(exec, "herdr workspace create failed"));
83
+ opened = parseRootPaneId(out, "herdr workspace create");
84
+ } else if (at === "tab") {
85
+ const out = exec("herdr", [
86
+ "tab",
87
+ "create",
88
+ ...opts.within ? ["--workspace", opts.within] : [],
89
+ "--cwd",
90
+ opts.cwd,
91
+ ...label,
92
+ ...env,
93
+ "--no-focus"
94
+ ]);
95
+ if (!out) throw new Error(withReason(exec, "herdr tab create failed"));
96
+ opened = parseRootPaneId(out, "herdr tab create");
97
+ } else {
98
+ const direction = at === "pane:down" ? "down" : "right";
99
+ const from = opts.from ? [opts.from.id] : ["--current"];
100
+ const size = opts.ratio != null ? ["--ratio", String(opts.ratio)] : [];
101
+ const out = exec("herdr", [
102
+ "pane",
103
+ "split",
104
+ ...from,
105
+ "--direction",
106
+ direction,
107
+ "--cwd",
108
+ opts.cwd,
109
+ ...size,
110
+ ...env
111
+ ]);
112
+ if (!out) throw new Error(withReason(exec, "herdr pane split failed"));
113
+ opened = parsePaneId(out);
114
+ if (opts.label) herdrMuxAdapter.rename(exec, opened, "pane", opts.label);
115
+ }
116
+ if (opts.launch) herdrMuxAdapter.submit(exec, opened, opts.launch);
117
+ return opened;
118
+ },
119
+ rename(exec, target, tier, name) {
120
+ exec("herdr", [
121
+ tier,
122
+ "rename",
123
+ target.id,
124
+ name
125
+ ]);
126
+ },
127
+ group() {},
128
+ worktree: herdrWorktreeCapability(),
129
+ sendText(exec, target, text) {
130
+ exec("herdr", [
131
+ "pane",
132
+ "send-text",
133
+ target.id,
134
+ text
135
+ ]);
136
+ },
137
+ sendKeys(exec, target, keys) {
138
+ exec("herdr", [
139
+ "pane",
140
+ "send-keys",
141
+ target.id,
142
+ ...keys
143
+ ]);
144
+ },
145
+ submit(exec, target, text) {
146
+ if (!text) {
147
+ exec("herdr", [
148
+ "pane",
149
+ "send-keys",
150
+ target.id,
151
+ "Enter"
152
+ ]);
153
+ return;
154
+ }
155
+ exec("herdr", [
156
+ "pane",
157
+ "run",
158
+ target.id,
159
+ text
160
+ ]);
161
+ },
162
+ read(exec, target, opts) {
163
+ const args = [
164
+ "pane",
165
+ "read",
166
+ target.id,
167
+ "--source",
168
+ "visible"
169
+ ];
170
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
171
+ return exec("herdr", args) ?? "";
172
+ },
173
+ focus(exec, target) {
174
+ const { workspaceId, tabId } = parsePaneLocation$1(exec("herdr", [
175
+ "pane",
176
+ "get",
177
+ target.id
178
+ ]), target.id);
179
+ exec("herdr", [
180
+ "workspace",
181
+ "focus",
182
+ workspaceId
183
+ ]);
184
+ exec("herdr", [
185
+ "tab",
186
+ "focus",
187
+ tabId
188
+ ]);
189
+ },
190
+ teardown(exec, target) {
191
+ exec("herdr", [
192
+ "pane",
193
+ "close",
194
+ target.id
195
+ ]);
196
+ },
197
+ paneExists(exec, target) {
198
+ return exec("herdr", [
199
+ "pane",
200
+ "read",
201
+ target.id,
202
+ "--source",
203
+ "visible"
204
+ ]) !== null;
205
+ },
206
+ isPaneFocused(exec, target) {
207
+ const out = exec("herdr", [
208
+ "pane",
209
+ "get",
210
+ target.id
211
+ ]);
212
+ if (out == null) return void 0;
213
+ try {
214
+ const focused = JSON.parse(out)?.result?.pane?.focused;
215
+ return typeof focused === "boolean" ? focused : void 0;
216
+ } catch {
217
+ return;
218
+ }
219
+ },
220
+ listPanes(exec) {
221
+ const out = exec("herdr", ["pane", "list"]);
222
+ if (!out) return [];
223
+ let panes;
224
+ try {
225
+ panes = JSON.parse(out)?.result?.panes;
226
+ } catch {
227
+ return [];
228
+ }
229
+ if (!Array.isArray(panes)) return [];
230
+ return panes.filter((p) => typeof p?.pane_id === "string").map((p) => {
231
+ const harness = p.agent || void 0;
232
+ const label = p.label || void 0;
233
+ return {
234
+ id: p.pane_id,
235
+ mux: "herdr",
236
+ ...harness !== void 0 ? { harness } : {},
237
+ ...p.cwd !== void 0 ? { cwd: p.cwd } : {},
238
+ ...label !== void 0 ? { label } : {}
239
+ };
240
+ });
241
+ },
242
+ regions: {
243
+ describeRegion(exec, target) {
244
+ return herdrRegionPanes(exec, target.id, herdrPaneDetails(exec));
245
+ },
246
+ /**
247
+ * herdr HAS a workspace tier, so the workspace is a fact the backend holds rather than one
248
+ * cyber-mux has to reconstruct: the caller's pane names its `workspace_id`, `tab list --workspace`
249
+ * enumerates that workspace's tabs, and `pane list --workspace` hands back every pane already
250
+ * stamped with the tab it sits in. No grouping tag is read here and none is written — the tier IS
251
+ * the group, which is exactly why `open` ignores `workspaceGroup` on this backend.
252
+ *
253
+ * The one indirection: geometry is per-PANE (`pane layout --pane`), never per-tab, so each tab's
254
+ * rects are fetched through any one pane that sits in it. That is safe and race-free, and both
255
+ * halves were established against 0.7.4: `pane layout` reports live geometry for an UNFOCUSED tab
256
+ * in a DIFFERENT workspace, so nothing has to be focused first and nothing moves while this runs.
257
+ *
258
+ * 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.7.4; it is socket-API-only, and this adapter speaks the CLI by
260
+ * design (so it composes with the synchronous `Exec` seam). The road is closed, hence the pane
261
+ * indirection.
262
+ */
263
+ describeWorkspace(exec, target) {
264
+ const { workspaceId } = parsePaneRecord(exec("herdr", [
265
+ "pane",
266
+ "get",
267
+ target.id
268
+ ]));
269
+ if (!workspaceId) throw new Error(withReason(exec, `herdr could not resolve the workspace around pane ${target.id}`));
270
+ const out = exec("herdr", [
271
+ "tab",
272
+ "list",
273
+ "--workspace",
274
+ workspaceId
275
+ ]);
276
+ if (!out) throw new Error(withReason(exec, `herdr could not enumerate the tabs of workspace ${workspaceId}`));
277
+ let reported;
278
+ try {
279
+ reported = JSON.parse(out)?.result?.tabs;
280
+ } catch {
281
+ throw new Error(`herdr tab list returned unparseable output: ${out.slice(0, 200)}`);
282
+ }
283
+ if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr reported no tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
284
+ const details = herdrPaneDetails(exec, workspaceId);
285
+ const tabs = [];
286
+ for (const reportedTab of reported) {
287
+ if (typeof reportedTab?.tab_id !== "string") continue;
288
+ const tabId = reportedTab.tab_id;
289
+ const anchor = [...details].find(([, detail]) => detail.tab === tabId)?.[0];
290
+ if (!anchor) throw new Error(`herdr reported no panes in tab ${tabId} of workspace ${workspaceId}`);
291
+ const tab = {
292
+ id: tabId,
293
+ panes: herdrRegionPanes(exec, anchor, details)
294
+ };
295
+ if (typeof reportedTab.label === "string" && reportedTab.label !== "") tab.label = reportedTab.label;
296
+ tabs.push(tab);
297
+ }
298
+ if (tabs.length === 0) throw new Error(`herdr reported no usable tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
299
+ return tabs;
300
+ }
301
+ }
302
+ };
303
+ /**
304
+ * The rects of the region `paneId` sits in, joined with the cwd/label half.
305
+ *
306
+ * Two sources, because herdr splits the answer across two verbs: `pane layout` reports the region's
307
+ * rects (`layout.panes[].rect`) but carries no cwd and no label, while `pane list` carries both and
308
+ * no geometry. Neither alone can build a template — hence `details` is passed IN, so a caller reading
309
+ * many tabs pays for that list once rather than once per tab.
310
+ *
311
+ * `layout.splits[]` is deliberately ignored even though it reports `direction` and `ratio` outright.
312
+ * It is FLAT — `[{id:"split_0_root",...},{id:"split_1_0",...}]` — so the tree is recoverable only by
313
+ * parsing the parent out of that id string, a convention herdr's CLI help never documents and could
314
+ * respell without warning. The rects say the same thing in a fact herdr does promise, so the
315
+ * derivation runs off those; see `RegionInspector.describeRegion` in `mux.ts`.
316
+ */
317
+ function herdrRegionPanes(exec, paneId, details) {
318
+ const out = exec("herdr", [
319
+ "pane",
320
+ "layout",
321
+ "--pane",
322
+ paneId
323
+ ]);
324
+ if (!out) throw new Error(withReason(exec, `herdr could not describe the region around pane ${paneId}`));
325
+ let reported;
326
+ try {
327
+ reported = JSON.parse(out)?.result?.layout?.panes;
328
+ } catch {
329
+ throw new Error(`herdr pane layout returned unparseable output: ${out.slice(0, 200)}`);
330
+ }
331
+ if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr pane layout reported no panes for ${paneId}: ${out.slice(0, 200)}`);
332
+ return reported.filter((p) => typeof p?.pane_id === "string").map((p) => {
333
+ const detail = details.get(p.pane_id);
334
+ const pane = {
335
+ id: p.pane_id,
336
+ rect: {
337
+ x: p.rect?.["x"] ?? 0,
338
+ y: p.rect?.["y"] ?? 0,
339
+ width: p.rect?.["width"] ?? 0,
340
+ height: p.rect?.["height"] ?? 0
341
+ }
342
+ };
343
+ if (detail?.cwd) pane.cwd = detail.cwd;
344
+ if (detail?.label) pane.label = detail.label;
345
+ return pane;
346
+ });
347
+ }
348
+ /**
349
+ * Each pane's cwd, label and tab, keyed by pane id — the half `pane layout` does not report.
350
+ *
351
+ * `workspace` scopes the list to one workspace when the caller has one to scope by; omitting it lists
352
+ * every pane herdr can see, which is what a single-region read wants (it keys by pane id and never
353
+ * cares which workspace a pane came from).
354
+ */
355
+ function herdrPaneDetails(exec, workspace) {
356
+ const details = /* @__PURE__ */ new Map();
357
+ const out = exec("herdr", [
358
+ "pane",
359
+ "list",
360
+ ...workspace ? ["--workspace", workspace] : []
361
+ ]);
362
+ if (!out) return details;
363
+ let panes;
364
+ try {
365
+ panes = JSON.parse(out)?.result?.panes;
366
+ } catch {
367
+ return details;
368
+ }
369
+ if (!Array.isArray(panes)) return details;
370
+ for (const pane of panes) {
371
+ if (typeof pane?.pane_id !== "string") continue;
372
+ details.set(pane.pane_id, {
373
+ cwd: pane.cwd,
374
+ label: pane.label,
375
+ tab: pane.tab_id
376
+ });
377
+ }
378
+ return details;
379
+ }
380
+ /**
381
+ * herdr's repeatable `--env KEY=VALUE` — spelled the same way by exactly three verbs: `pane split`,
382
+ * `workspace create` and `tab create`, each backed by a native `env` Record in the socket schema
383
+ * (protocol 16).
384
+ *
385
+ * `worktree create`/`worktree open` are deliberately NOT in that list: their params are
386
+ * `[base, branch, cwd, focus, label, path, workspace_id]` and
387
+ * `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and 0.7.4 rejects the flag with
388
+ * `unknown option: --env`. A caller needing env on that route uses the command-prefix fallback.
389
+ */
390
+ function envFlags(env) {
391
+ return env ? Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]) : [];
392
+ }
393
+ /**
394
+ * Launch a command in a worktree's root pane, carrying env the worktree verb could not set at birth.
395
+ * The prefix-or-warn rule is the seam's (`env-fallback.ts`); this is the one route that invokes it,
396
+ * because it is the one route that loses env. With a command, env rides in as a prefix; with none and
397
+ * env asked for, it warns to stderr (stdout stays machine-readable) rather than dropping in silence.
398
+ */
399
+ function carryLaunch(exec, target, env, launch) {
400
+ const fallback = envFallback(env, launch);
401
+ if (fallback.kind === "dropped") {
402
+ process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this worktree's workspace and no command was given to carry it — herdr worktree create/open take no env parameter
403
+ `);
404
+ return;
405
+ }
406
+ if (fallback.command !== void 0) herdrMuxAdapter.submit(exec, target, fallback.command);
407
+ }
408
+ /**
409
+ * `herdr pane split` emits a JSON envelope, not a bare id:
410
+ * `{"id":"cli:pane:split","result":{"pane":{"pane_id":"w3:pB", ...},"type":"pane_info"}}`.
411
+ * The pane id herdr's other `pane` subcommands accept lives at `.result.pane.pane_id`. Extract it —
412
+ * passing the whole blob downstream lands it in a filename and blows the path length limit.
413
+ */
414
+ function parsePaneId(out) {
415
+ return parseOpenedPane(out, "herdr pane split", "pane");
416
+ }
417
+ /**
418
+ * `herdr pane get <id>` emits `{"result":{"pane":{"workspace_id":...,"tab_id":...,...}}}`, or an
419
+ * error envelope when the id no longer names a live pane. Every unresolvable shape — `out` is null
420
+ * (an Exec failure), the JSON does not parse, or a field is missing/empty/not a string — folds to the
421
+ * field simply being absent, so each caller states its OWN failure rather than inheriting one
422
+ * phrased for somebody else's verb.
423
+ */
424
+ function parsePaneRecord(out) {
425
+ if (out == null) return {};
426
+ try {
427
+ const pane = JSON.parse(out)?.result?.pane;
428
+ return {
429
+ workspaceId: nonEmpty(pane?.workspace_id),
430
+ tabId: nonEmpty(pane?.tab_id)
431
+ };
432
+ } catch {
433
+ return {};
434
+ }
435
+ }
436
+ function nonEmpty(value) {
437
+ return typeof value === "string" && value !== "" ? value : void 0;
438
+ }
439
+ /**
440
+ * The pane's workspace and tab, or a throw — so `focus` never issues a workspace/tab switch against a
441
+ * pane it couldn't actually resolve.
442
+ */
443
+ function parsePaneLocation$1(out, id) {
444
+ const { workspaceId, tabId } = parsePaneRecord(out);
445
+ if (!workspaceId || !tabId) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
446
+ return {
447
+ workspaceId,
448
+ tabId
449
+ };
450
+ }
451
+ /**
452
+ * herdr binds a git worktree to a workspace as a first-class record, and that binding is what its UI
453
+ * groups a repo's checkouts by. Only `worktree create`/`worktree open` produce it: `git worktree add`
454
+ * followed by `workspace create --cwd <checkout>` yields a workspace herdr does not know is a
455
+ * worktree at all, left out of the group. Hence this capability — see `WorktreeWorkspaceCapability`
456
+ * for what it deliberately does not own.
457
+ *
458
+ * Every call pins the source repo with `--cwd <primaryRoot>` rather than relying on the caller's
459
+ * ambient process cwd (matching how the git adapter always passes `-C <primaryRoot>`), and opens
460
+ * with `--no-focus` so spawning never steals the caller's attention.
461
+ */
462
+ function herdrWorktreeCapability() {
463
+ return {
464
+ createInWorkspace(exec, opts) {
465
+ const args = [
466
+ "worktree",
467
+ "create",
468
+ "--cwd",
469
+ opts.primaryRoot,
470
+ "--branch",
471
+ opts.branch,
472
+ "--path",
473
+ opts.path
474
+ ];
475
+ if (opts.base) args.push("--base", opts.base);
476
+ if (opts.label) args.push("--label", opts.label);
477
+ args.push("--no-focus");
478
+ const out = exec("herdr", args);
479
+ if (!out) throw new Error(withReason(exec, "herdr worktree create failed"));
480
+ const created = parseWorktreeWorkspace(out, "herdr worktree create");
481
+ carryLaunch(exec, created.target, opts.env, opts.launch);
482
+ return created;
483
+ },
484
+ openInWorkspace(exec, opts) {
485
+ const args = [
486
+ "worktree",
487
+ "open",
488
+ "--cwd",
489
+ opts.primaryRoot,
490
+ "--path",
491
+ opts.path
492
+ ];
493
+ if (opts.label) args.push("--label", opts.label);
494
+ args.push("--no-focus");
495
+ const out = exec("herdr", args);
496
+ if (!out) throw new Error(withReason(exec, "herdr worktree open failed"));
497
+ const opened = parseWorktreeWorkspace(out, "herdr worktree open");
498
+ carryLaunch(exec, opened.target, opts.env, opts.launch);
499
+ return opened;
500
+ },
501
+ bindings(exec, opts) {
502
+ return parseWorktreeBindings(exec("herdr", [
503
+ "worktree",
504
+ "list",
505
+ "--cwd",
506
+ opts.primaryRoot
507
+ ]));
508
+ },
509
+ releaseWorkspace(exec, workspace) {
510
+ exec("herdr", [
511
+ "workspace",
512
+ "close",
513
+ workspace
514
+ ]);
515
+ }
516
+ };
517
+ }
518
+ /**
519
+ * `herdr workspace create` and `herdr tab create` both emit their new root pane at
520
+ * `.result.root_pane.pane_id` (a different path than `pane split`'s `.result.pane.pane_id`).
521
+ * `label` names the command in error messages (e.g. "herdr workspace create").
522
+ */
523
+ function parseRootPaneId(out, label) {
524
+ return parseOpenedPane(out, label, "root_pane");
525
+ }
526
+ /**
527
+ * Every pane herdr emits carries its own `workspace_id` alongside its `pane_id`, on EVERY route —
528
+ * `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). Verified
530
+ * against herdr 0.7.4. That is why the workspace costs no extra call: it rides in on the same output
531
+ * the pane id is already read from, so probing for it separately would buy nothing and cost a round
532
+ * trip per open.
533
+ *
534
+ * The pane id is required — a route that cannot name its pane has failed. The workspace is NOT: it
535
+ * is read opportunistically and left absent when missing rather than throwing, so a herdr build that
536
+ * stops emitting it degrades to "cannot say" instead of breaking `open` outright. Absent is a
537
+ * meaning this seam already has (`OpenedPane.workspace`); a hard failure here would be inventing a
538
+ * new one for a field no caller is required to use.
539
+ */
540
+ function parseOpenedPane(out, label, key) {
541
+ let pane;
542
+ try {
543
+ pane = JSON.parse(out)?.result?.[key];
544
+ } catch {
545
+ throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
546
+ }
547
+ const paneId = pane?.pane_id;
548
+ if (typeof paneId !== "string" || paneId === "") throw new Error(`${label} output had no result.${key}.pane_id: ${out.slice(0, 200)}`);
549
+ const tab = pane?.tab_id;
550
+ if (typeof tab !== "string" || tab === "") throw new Error(`${label} output had no result.${key}.tab_id: ${out.slice(0, 200)}`);
551
+ const workspace = pane?.workspace_id;
552
+ return typeof workspace === "string" && workspace !== "" ? {
553
+ id: paneId,
554
+ tab,
555
+ workspace
556
+ } : {
557
+ id: paneId,
558
+ tab
559
+ };
560
+ }
561
+ /**
562
+ * `herdr worktree create` and `herdr worktree open` emit the same envelope: the root pane at
563
+ * `.result.root_pane.pane_id` (as `workspace create` does), the checkout at
564
+ * `.result.worktree.{path,branch}`, and the bound workspace at `.result.workspace.workspace_id`.
565
+ * That workspace id IS the binding — the whole reason to route through these instead of plain git.
566
+ * `label` names the command in error messages (e.g. "herdr worktree create").
567
+ *
568
+ * The root pane is read through `parseOpenedPane`, NOT re-parsed here: `root_pane` is the same record
569
+ * `workspace create` emits, so it carries the same `tab_id`, and one spelling is what keeps the two
570
+ * routes from disagreeing about a field both report. That tab is the region's root tab — what lets a
571
+ * caller handed this workspace group or rename it without reaching for the pane id, which would be
572
+ * green on tmux and silently broken on herdr.
573
+ */
574
+ function parseWorktreeWorkspace(out, label) {
575
+ let parsed;
576
+ try {
577
+ parsed = JSON.parse(out);
578
+ } catch {
579
+ throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
580
+ }
581
+ const result = parsed?.result;
582
+ const target = parseOpenedPane(out, label, "root_pane");
583
+ const workspace = result?.workspace?.workspace_id;
584
+ const path = result?.worktree?.path;
585
+ const branch = result?.worktree?.branch;
586
+ if (typeof path !== "string" || path === "" || typeof branch !== "string" || branch === "") throw new Error(`${label} output had no result.worktree.{path,branch}: ${out.slice(0, 200)}`);
587
+ if (typeof workspace !== "string" || workspace === "") throw new Error(`${label} output had no result.workspace.workspace_id: ${out.slice(0, 200)}`);
588
+ return {
589
+ target,
590
+ worktree: {
591
+ root: resolve(path),
592
+ branch
593
+ },
594
+ workspace
595
+ };
596
+ }
597
+ /**
598
+ * `herdr worktree list` reports every worktree of the repo, each carrying `open_workspace_id` ONLY
599
+ * while a workspace is currently open on it. Everything else it reports (branch, linked, prunable)
600
+ * is herdr re-reading git — deliberately ignored here; git answers those for every backend.
601
+ * Defensive like `listPanes`: a query that cannot be read reports nothing rather than throwing.
602
+ */
603
+ function parseWorktreeBindings(out) {
604
+ const bindings = /* @__PURE__ */ new Map();
605
+ if (!out) return bindings;
606
+ let parsed;
607
+ try {
608
+ parsed = JSON.parse(out);
609
+ } catch {
610
+ return bindings;
611
+ }
612
+ const worktrees = parsed?.result?.worktrees ?? [];
613
+ if (!Array.isArray(worktrees)) return bindings;
614
+ for (const entry of worktrees) {
615
+ const path = entry?.path;
616
+ const workspace = entry?.open_workspace_id;
617
+ if (typeof path === "string" && path !== "" && typeof workspace === "string" && workspace !== "") bindings.set(normalizeWorktreePath(path), workspace);
618
+ }
619
+ return bindings;
620
+ }
621
+ //#endregion
622
+ //#region src/mux.tmux.ts
623
+ /**
624
+ * The tmux window user option `MuxOpenOptions.workspaceGroup` is stored in. A user option (the
625
+ * `@` prefix) is tmux's own mechanism for a value it stores but never interprets, so tmux carries
626
+ * the tag without cyber-mux teaching it anything: it survives a window rename, and `list-windows`
627
+ * both reads it back (`#{@cm_ws}`) and filters on it server-side (`-f '#{==:#{@cm_ws},<id>}'`).
628
+ *
629
+ * Named here rather than spelled at each use so the write side and every read side cannot drift.
630
+ * Server-lifetime, like every window: it dies with the tmux server, along with the windows it tags.
631
+ */
632
+ const TMUX_WORKSPACE_GROUP_OPTION = "@cm_ws";
633
+ /**
634
+ * The tmux window user option a grouped window's OWN name is stored in — the name the caller gave the
635
+ * tab, beside the group id, because tmux's single `window_name` field no longer holds it.
636
+ *
637
+ * tmux has ONE name field per space. A caller that composes a display name out of a tab's name
638
+ * (`pool - editor`) has destroyed `editor`, and there is no sound way back: splitting on the separator
639
+ * is ambiguous (`acme - beta - main` reads two legal ways), and reading the display name verbatim
640
+ * re-prefixes it on every round trip (`pool - pool - editor`). So the original is stored here and read
641
+ * back from here — the same rule the group id follows, one tier down. The display name is a human's to
642
+ * read; this is what a machine reads.
643
+ *
644
+ * A user option (the `@` prefix) for `TMUX_WORKSPACE_GROUP_OPTION`'s reasons exactly: tmux stores it
645
+ * without interpreting it, it survives a window rename, and `list-windows` reads it back
646
+ * (`#{@cm_tab}`). Named here rather than spelled at each use so the write side and every read side
647
+ * cannot drift.
648
+ */
649
+ const TMUX_TAB_NAME_OPTION = "@cm_tab";
650
+ /** tmux backend — detected via `$TMUX`. */
651
+ const tmuxMuxAdapter = {
652
+ name: "tmux",
653
+ canSizeSplits: true,
654
+ open(exec, opts) {
655
+ const at = opts.at ?? "tab";
656
+ const window = at === "workspace" || at === "tab";
657
+ const env = opts.env ? Object.entries(opts.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) : [];
658
+ const group = window && opts.workspaceGroup != null;
659
+ const format = "#{pane_id} #{window_id}";
660
+ let args;
661
+ if (window) args = [
662
+ "new-window",
663
+ "-d",
664
+ ...env,
665
+ "-c",
666
+ opts.cwd,
667
+ "-P",
668
+ "-F",
669
+ format
670
+ ];
671
+ else {
672
+ const from = opts.from ? ["-t", opts.from.id] : [];
673
+ const size = opts.ratio != null ? ["-l", toTmuxSize(opts.ratio)] : [];
674
+ args = [
675
+ "split-window",
676
+ at === "pane:down" ? "-v" : "-h",
677
+ ...from,
678
+ ...size,
679
+ ...env,
680
+ "-c",
681
+ opts.cwd,
682
+ "-P",
683
+ "-F",
684
+ format
685
+ ];
686
+ }
687
+ if (window && opts.label) args.splice(1, 0, "-n", opts.label);
688
+ const out = exec("tmux", args);
689
+ if (!out) throw new Error(withReason(exec, `tmux ${args[0]} failed`));
690
+ const [pane, windowId] = splitOpenReport(out, args[0]);
691
+ const target = {
692
+ id: pane,
693
+ tab: windowId
694
+ };
695
+ if (group && windowId) tmuxMuxAdapter.group(exec, { id: windowId }, opts.workspaceGroup);
696
+ if (!window && opts.label) tmuxMuxAdapter.rename(exec, target, "pane", opts.label);
697
+ if (opts.launch) tmuxMuxAdapter.submit(exec, target, opts.launch);
698
+ return target;
699
+ },
700
+ rename(exec, target, tier, name) {
701
+ if (tier === "tab") {
702
+ exec("tmux", [
703
+ "rename-window",
704
+ "-t",
705
+ target.id,
706
+ name
707
+ ]);
708
+ return;
709
+ }
710
+ exec("tmux", [
711
+ "select-pane",
712
+ "-t",
713
+ target.id,
714
+ "-T",
715
+ name
716
+ ]);
717
+ },
718
+ group(exec, target, group, name) {
719
+ exec("tmux", [
720
+ "set-option",
721
+ "-w",
722
+ "-t",
723
+ target.id,
724
+ TMUX_WORKSPACE_GROUP_OPTION,
725
+ group
726
+ ]);
727
+ if (name !== void 0) exec("tmux", [
728
+ "set-option",
729
+ "-w",
730
+ "-t",
731
+ target.id,
732
+ TMUX_TAB_NAME_OPTION,
733
+ name
734
+ ]);
735
+ },
736
+ sendText(exec, target, text) {
737
+ exec("tmux", [
738
+ "send-keys",
739
+ "-t",
740
+ target.id,
741
+ "-l",
742
+ text
743
+ ]);
744
+ },
745
+ sendKeys(exec, target, keys) {
746
+ exec("tmux", [
747
+ "send-keys",
748
+ "-t",
749
+ target.id,
750
+ ...keys.map(toTmuxKey)
751
+ ]);
752
+ },
753
+ submit(exec, target, text) {
754
+ if (!text) {
755
+ exec("tmux", [
756
+ "send-keys",
757
+ "-t",
758
+ target.id,
759
+ "Enter"
760
+ ]);
761
+ return;
762
+ }
763
+ tmuxMuxAdapter.sendText(exec, target, text);
764
+ exec("tmux", [
765
+ "send-keys",
766
+ "-t",
767
+ target.id,
768
+ "Enter"
769
+ ]);
770
+ },
771
+ read(exec, target, opts) {
772
+ const args = [
773
+ "capture-pane",
774
+ "-p",
775
+ "-t",
776
+ target.id
777
+ ];
778
+ if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
779
+ return exec("tmux", args) ?? "";
780
+ },
781
+ focus(exec, target) {
782
+ const { sessionName, windowId } = parsePaneLocation(exec("tmux", [
783
+ "list-panes",
784
+ "-a",
785
+ "-F",
786
+ "#{pane_id} #{session_name} #{window_id}"
787
+ ]), target.id);
788
+ exec("tmux", [
789
+ "switch-client",
790
+ "-t",
791
+ sessionName
792
+ ]);
793
+ exec("tmux", [
794
+ "select-window",
795
+ "-t",
796
+ windowId
797
+ ]);
798
+ exec("tmux", [
799
+ "select-pane",
800
+ "-t",
801
+ target.id
802
+ ]);
803
+ },
804
+ teardown(exec, target) {
805
+ exec("tmux", [
806
+ "kill-pane",
807
+ "-t",
808
+ target.id
809
+ ]);
810
+ },
811
+ paneExists(exec, target) {
812
+ if (exec("tmux", [
813
+ "has-session",
814
+ "-t",
815
+ target.id
816
+ ]) !== null) return true;
817
+ return (exec("tmux", [
818
+ "list-panes",
819
+ "-a",
820
+ "-F",
821
+ "#{pane_id}"
822
+ ]) ?? "").split("\n").includes(target.id);
823
+ },
824
+ isPaneFocused(exec, target) {
825
+ const out = exec("tmux", [
826
+ "list-panes",
827
+ "-a",
828
+ "-F",
829
+ "#{pane_id} #{pane_active} #{window_active} #{session_attached}"
830
+ ]);
831
+ if (!out) return void 0;
832
+ const line = out.split("\n").find((l) => l.split(" ")[0] === target.id);
833
+ if (!line) return void 0;
834
+ const [, paneActive, windowActive, sessionAttached] = line.split(" ");
835
+ return paneActive === "1" && windowActive === "1" && sessionAttached !== "0" && sessionAttached !== void 0;
836
+ },
837
+ /**
838
+ * Tab-separated, not space — the same rule `describeTmuxRegion` follows, and for the same reason:
839
+ * `pane_current_path` and `pane_title` can both contain spaces. The old space-separated format
840
+ * recovered the cwd by rejoining everything after the command, which works only while the cwd is
841
+ * the LAST field. A label is a human's and may hold anything, so appending one to that format would
842
+ * make both fields unrecoverable — `my worker` and `/repo/my dir` cannot be told apart by a space.
843
+ * A tab can appear in neither id nor command, and the two free-text fields are separated by one.
844
+ */
845
+ listPanes(exec) {
846
+ const out = exec("tmux", [
847
+ "list-panes",
848
+ "-a",
849
+ "-F",
850
+ "#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host}"
851
+ ]);
852
+ if (!out) return [];
853
+ return out.split("\n").filter(Boolean).map((line) => {
854
+ const [id, , cwd, title, host] = line.split(" ");
855
+ const pane = {
856
+ id: id ?? "",
857
+ mux: "tmux"
858
+ };
859
+ if (cwd) pane.cwd = cwd;
860
+ const label = paneLabel(title, host);
861
+ if (label) pane.label = label;
862
+ return pane;
863
+ }).filter((p) => p.id !== "");
864
+ },
865
+ regions: {
866
+ describeRegion(exec, target) {
867
+ return describeTmuxRegion(exec, target.id);
868
+ },
869
+ /**
870
+ * tmux has NO workspace tier — `workspace` and `tab` both collapse onto a Window — so a workspace
871
+ * is not a fact this backend holds. What it holds is the grouping TAG the walk wrote
872
+ * (`MuxOpenOptions.workspaceGroup`, stored in a window user option), so the read here is
873
+ * literally *"which windows carry this group id"*.
874
+ *
875
+ * The tag, never the label. `list-windows -a` spans SESSIONS, so a bare name match would
876
+ * over-collect a same-named window from another session, and taking the workspace off a
877
+ * `<workspace> - <tab>` label is unsound in the first place (`acme - beta - main` splits two ways,
878
+ * both legal). `-f '#{==:#{@cm_ws},<id>}'` keys on what actually identifies the group, filtered
879
+ * server-side — the tag survives a window rename, which a name-encoded grouping does not.
880
+ *
881
+ * A window with NO tag is a workspace of ONE: the honest answer for a window nobody grouped, and
882
+ * it costs no further call — the caller's own window is the whole workspace.
883
+ */
884
+ describeWorkspace(exec, target) {
885
+ const out = exec("tmux", [
886
+ "display-message",
887
+ "-p",
888
+ "-t",
889
+ target.id,
890
+ `#{window_id}\t#{${TMUX_WORKSPACE_GROUP_OPTION}}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`
891
+ ]);
892
+ if (!out) throw new Error(withReason(exec, `tmux could not resolve the workspace around pane ${target.id}`));
893
+ const [windowId, group, ownName, ...nameParts] = out.split("\n")[0].split(" ");
894
+ if (!windowId) throw new Error(`tmux did not report the window around pane ${target.id}`);
895
+ if (!group) return [tmuxTab(exec, windowId, ownName, nameParts.join(" "))];
896
+ const listed = exec("tmux", [
897
+ "list-windows",
898
+ "-a",
899
+ "-F",
900
+ `#{window_id}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`,
901
+ "-f",
902
+ `#{==:#{${TMUX_WORKSPACE_GROUP_OPTION}},${group}}`
903
+ ]);
904
+ if (!listed) throw new Error(withReason(exec, `tmux could not enumerate the windows grouped as ${group}`));
905
+ const tabs = listed.split("\n").filter(Boolean).map((line) => line.split(" ")).filter(([id]) => Boolean(id)).map(([id, own, ...rest]) => tmuxTab(exec, id, own, rest.join(" ")));
906
+ if (tabs.length === 0) throw new Error(`tmux reported no windows grouped as ${group}`);
907
+ return tabs;
908
+ }
909
+ }
910
+ };
911
+ /**
912
+ * One window, read as a tab: its id, the tab's OWN name, and its region's geometry.
913
+ *
914
+ * `ownName` is what `group` stored (`TMUX_TAB_NAME_OPTION`) and it WINS, because `windowName` is the
915
+ * display name — on a grouped window that is the composed `pool - editor`, whose `editor` tmux's
916
+ * single name field no longer holds. Reporting the display name instead would compound the prefix on
917
+ * every capture/apply round trip (`pool - pool - editor`), and splitting it back apart is the unsound
918
+ * parse the option exists to refuse.
919
+ *
920
+ * The window name is the FALLBACK, not a second guess: a window carrying no stored name is one nobody
921
+ * composed a display name for, so its name already IS its own name. That covers the untagged window —
922
+ * a workspace of one — and any window a caller grouped without naming.
923
+ */
924
+ function tmuxTab(exec, windowId, ownName, windowName) {
925
+ const tab = {
926
+ id: windowId,
927
+ panes: describeTmuxRegion(exec, windowId)
928
+ };
929
+ const label = ownName || windowName;
930
+ if (label) tab.label = label;
931
+ return tab;
932
+ }
933
+ /**
934
+ * Every pane of the region `id` names, with its rectangle. `id` is a pane id (that pane's own window)
935
+ * or a window id (that window) — `list-panes -t` resolves both, which is what lets the region read and
936
+ * the workspace read share one query instead of two that could drift apart.
937
+ *
938
+ * `-t` scopes `list-panes` to ONE window — the region tier, which is what capture captures. Without
939
+ * `-a`, so this never reaches the panes of some other window.
940
+ *
941
+ * `#{pane_left}`/`#{pane_top}` are window-relative, and the widths exclude the divider column tmux
942
+ * draws between panes (a 200-wide window split side by side reports 119 + 80, not 200) — both are
943
+ * exactly what `RegionPane.rect` documents, so nothing is adjusted here.
944
+ *
945
+ * Tab-separated, not space: `pane_current_path` and `pane_title` can both contain spaces, and
946
+ * splitting a path on spaces is how a directory with one in it silently becomes the wrong pane.
947
+ */
948
+ function describeTmuxRegion(exec, id) {
949
+ const out = exec("tmux", [
950
+ "list-panes",
951
+ "-t",
952
+ id,
953
+ "-F",
954
+ "#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_current_path} #{pane_title} #{host}"
955
+ ]);
956
+ if (!out) throw new Error(withReason(exec, `tmux could not describe the region around pane ${id}`));
957
+ const panes = [];
958
+ for (const line of out.split("\n").filter(Boolean)) {
959
+ const [paneId, left, top, width, height, cwd, title, host] = line.split(" ");
960
+ if (!paneId) continue;
961
+ const pane = {
962
+ id: paneId,
963
+ rect: {
964
+ x: Number(left),
965
+ y: Number(top),
966
+ width: Number(width),
967
+ height: Number(height)
968
+ }
969
+ };
970
+ if (cwd) pane.cwd = cwd;
971
+ const label = paneLabel(title, host);
972
+ if (label) pane.label = label;
973
+ panes.push(pane);
974
+ }
975
+ if (panes.length === 0) throw new Error(`tmux reported no panes in the region around pane ${id}`);
976
+ return panes;
977
+ }
978
+ /**
979
+ * A tmux pane's label — its title, unless that title is the hostname tmux handed it.
980
+ *
981
+ * **tmux has no "unset title"**: it defaults `pane_title` to the hostname, so a pane nobody ever named
982
+ * reports a name nobody chose, and every pane in an untouched session reports the SAME one. Exporting
983
+ * that would label them all `zeta`, and `zeta` would then resolve to every pane in the session —
984
+ * ambiguity manufactured out of nothing. A title that differs from the host is one someone set
985
+ * (cyber-mux's own `select-pane -T` among them), so it is the author's and survives.
986
+ *
987
+ * One home for the rule, called by BOTH reads — `listPanes` (which a name resolves against) and
988
+ * `describeTmuxRegion` (which a capture exports). Two spellings of a heuristic this load-bearing is
989
+ * how the listing and the capture come to disagree about which panes are named.
990
+ *
991
+ * The comparison is the workaround, not the shape of the thing: herdr has the honest primitive and
992
+ * omits the key outright until a pane is renamed, so it needs no rule at all.
993
+ */
994
+ function paneLabel(title, host) {
995
+ return title && title !== host ? title : void 0;
996
+ }
997
+ /**
998
+ * The `-P -F '#{pane_id}\t#{window_id}'` report EVERY open asks for, split back into its two ids.
999
+ * Tab-separated because neither id can contain a tab.
1000
+ *
1001
+ * A report that does not carry both throws rather than returning half an answer: the window is the
1002
+ * pane's tab, which `OpenedPane.tab` promises is always present, and it is also what a grouping open
1003
+ * tags. Guessing either would be worse than failing — a caller would name or group nothing and never
1004
+ * learn it.
1005
+ */
1006
+ function splitOpenReport(out, command) {
1007
+ const [pane, windowId] = out.split(" ");
1008
+ if (!pane || !windowId) throw new Error(`tmux ${command} did not report the new pane's id and window id`);
1009
+ return [pane, windowId];
1010
+ }
1011
+ /**
1012
+ * `ratio` is the fraction kept by the ORIGINAL pane; tmux's `-l` sizes the NEW one. So this INVERTS
1013
+ * — `1 - ratio` — where herdr's `--ratio` passes the same number through untouched. The two backends
1014
+ * genuinely convert in opposite directions, and applying the inversion to both (or to neither) is
1015
+ * the way this gets silently backwards: a 0.333 template would size the original pane at 67%.
1016
+ *
1017
+ * Percent rather than cells: tmux takes `-l` as either, and a percentage is the only form that means
1018
+ * the same thing without first querying the region's size.
1019
+ */
1020
+ function toTmuxSize(ratio) {
1021
+ return `${Math.round((1 - ratio) * 100)}%`;
1022
+ }
1023
+ /**
1024
+ * The core vocabulary's tmux spelling. Exactly one member differs — probed, not read off tmux(1):
1025
+ * tmux has no `Backspace` key name, so it would *type* the word (its unrecognized-token fallback);
1026
+ * its name for that key is `BSpace` (tmux(1): "the following special key names are accepted: Up,
1027
+ * Down, Left, Right, BSpace, BTab, DC ..."). Every other core key — `Up` `Down` `Left` `Right`
1028
+ * `Enter` `Escape` `Tab` `Space` `C-c` `F1`-`F12` — is already tmux's own name for it.
1029
+ *
1030
+ * Deliberately a rename table, NOT a validation table: a token outside the core is forwarded
1031
+ * verbatim (the contract), so this must not reject what it does not recognize. Keeping a full tmux
1032
+ * key list here would make the passthrough a second vocabulary to maintain.
1033
+ */
1034
+ const TMUX_KEY_RENAMES = { Backspace: "BSpace" };
1035
+ function toTmuxKey(key) {
1036
+ return TMUX_KEY_RENAMES[key] ?? key;
1037
+ }
1038
+ /**
1039
+ * `tmux list-panes -a -F '#{pane_id} #{session_name} #{window_id}'` lists every pane server-wide.
1040
+ * Resolving fails — no line's pane id matches `id` — when the pane no longer exists in the backend,
1041
+ * and that must throw so `focus` never issues a switch-client/select-window against a pane it
1042
+ * couldn't actually resolve.
1043
+ */
1044
+ function parsePaneLocation(out, id) {
1045
+ const line = (out ?? "").split("\n").find((l) => l.split(" ")[0] === id);
1046
+ if (!line) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
1047
+ const [, sessionName, windowId] = line.split(" ");
1048
+ return {
1049
+ sessionName,
1050
+ windowId
1051
+ };
1052
+ }
1053
+ //#endregion
1054
+ //#region src/new-id.ts
1055
+ /** The real id source — a v4 UUID. The shell binds this; core takes `NewId` as a parameter. */
1056
+ const nodeNewId = () => randomUUID();
1057
+ //#endregion
1058
+ //#region src/mux.wezterm.ts
1059
+ /**
1060
+ * WezTerm backend — detected via `$WEZTERM_PANE`. Drives WezTerm's built-in multiplexer through
1061
+ * `wezterm cli …` (https://wezterm.org/cli/general.html), the same synchronous-CLI shape tmux and
1062
+ * herdr already give `Exec`.
1063
+ *
1064
+ * Probed from `wezterm cli --help`/the CLI reference docs only — there is no live WezTerm GUI in
1065
+ * this sandbox, so nothing here carries the "verified against a live binary" claim `session.tmux.ts`
1066
+ * and `session.herdr.ts` make. Several real capability gaps fell out of that probe, not just missing
1067
+ * polish:
1068
+ *
1069
+ * - **No `--env` on `spawn`/`split-pane` at all.** Unlike herdr (native everywhere except one
1070
+ * worktree route), WezTerm's CLI has no env flag on ANY space-creating command — every route is
1071
+ * the exception, so `open`'s env always rides the `envFallback` compensation (a `env K=V` prefix
1072
+ * on the launch command, or a stderr warning with none to ride), never a native flag.
1073
+ * - **No way to title a PANE**, at birth or after — `set-tab-title`/`set-window-title` exist, there
1074
+ * is no pane equivalent. `rename(..., 'pane', …)` throws; `open`'s pane-tier `label` degrades to a
1075
+ * stderr warning rather than silently dropping it or failing the whole open.
1076
+ * - **No focus-query primitive** — `list --format json`'s documented fields carry no active/focused
1077
+ * indicator for a pane, tab, or window. `isPaneFocused` always answers `undefined`, which is the
1078
+ * seam's own honest answer for "no primitive to report focus", not a workaround.
1079
+ * - **No per-key press primitive** — there is no `send-keys`-shaped verb, only `send-text`. The core
1080
+ * vocabulary is instead realized by encoding each key as its raw terminal byte sequence and typing
1081
+ * it via `send-text --no-paste`; see `WEZTERM_KEY_BYTES`.
1082
+ * - **No pane geometry** — `list --format json` reports a pane's `size` (rows/cols) but no position,
1083
+ * so there is nothing to build a `PaneRect` from. `describeRegion`/`describeWorkspace` are omitted
1084
+ * entirely, the same optional-omission `template save` already handles for a backend that cannot.
1085
+ * - **No git-worktree concept in the CLI at all** — no `worktree` subcommand, so like tmux this
1086
+ * backend never binds one to a workspace; `worktree` is omitted.
1087
+ *
1088
+ * `spawn`/`split-pane` report only the new pane's bare id — unlike tmux/herdr, which embed the tab
1089
+ * (and workspace) in the same `-F`/JSON envelope the pane id rides out on. So `OpenedPane.tab` costs
1090
+ * a follow-up `wezterm cli list --format json` lookup here, not a free read of output already held.
1091
+ */
1092
+ /**
1093
+ * Build the WezTerm adapter over its injected effects. The only effect it takes is `newId` — the id
1094
+ * source for the fresh workspace name minted when the caller opens a `workspace` without a `label`.
1095
+ * Exported as a factory (with `weztermMuxAdapter` binding the real one below) so a test can inject a
1096
+ * deterministic id instead of a UUID, exactly the seam `Exec`/`TemplateStore` already are.
1097
+ */
1098
+ function createWeztermAdapter(deps) {
1099
+ const adapter = {
1100
+ name: "wezterm",
1101
+ canSizeSplits: true,
1102
+ open(exec, opts) {
1103
+ const at = opts.at ?? "tab";
1104
+ if (at === "workspace") {
1105
+ const workspace = opts.label ?? `cyber-mux-${deps.newId().slice(0, 8)}`;
1106
+ const out = exec("wezterm", [
1107
+ "cli",
1108
+ "spawn",
1109
+ "--new-window",
1110
+ "--workspace",
1111
+ workspace,
1112
+ "--cwd",
1113
+ opts.cwd
1114
+ ]);
1115
+ if (!out) throw new Error(withReason(exec, "wezterm cli spawn --new-window failed"));
1116
+ const pane = out.trim();
1117
+ if (!pane) throw new Error("wezterm cli spawn --new-window did not report the new pane id");
1118
+ const opened = {
1119
+ id: pane,
1120
+ tab: resolveTab(exec, pane),
1121
+ workspace
1122
+ };
1123
+ runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
1124
+ return opened;
1125
+ }
1126
+ if (at === "tab") {
1127
+ const out = exec("wezterm", [
1128
+ "cli",
1129
+ "spawn",
1130
+ ...opts.within ? ["--window-id", resolveWorkspaceWindow(exec, opts.within)] : [],
1131
+ "--cwd",
1132
+ opts.cwd
1133
+ ]);
1134
+ if (!out) throw new Error(withReason(exec, "wezterm cli spawn failed"));
1135
+ const pane = out.trim();
1136
+ if (!pane) throw new Error("wezterm cli spawn did not report the new pane id");
1137
+ const opened = withTabAndWorkspace(exec, pane);
1138
+ if (opts.label) adapter.rename(exec, { id: opened.tab }, "tab", opts.label);
1139
+ runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
1140
+ return opened;
1141
+ }
1142
+ const direction = at === "pane:down" ? ["--bottom"] : ["--right"];
1143
+ const from = opts.from ? ["--pane-id", opts.from.id] : [];
1144
+ const size = opts.ratio != null ? ["--percent", toWeztermSize(opts.ratio)] : [];
1145
+ const out = exec("wezterm", [
1146
+ "cli",
1147
+ "split-pane",
1148
+ ...direction,
1149
+ ...from,
1150
+ ...size,
1151
+ "--cwd",
1152
+ opts.cwd
1153
+ ]);
1154
+ if (!out) throw new Error(withReason(exec, "wezterm cli split-pane failed"));
1155
+ const pane = out.trim();
1156
+ if (!pane) throw new Error("wezterm cli split-pane did not report the new pane id");
1157
+ const opened = withTabAndWorkspace(exec, pane);
1158
+ if (opts.label) process.stderr.write(`wezterm cannot name a pane — "${opts.label}" was not set on pane ${opened.id}\n`);
1159
+ runLaunch$1(adapter, exec, opened, opts.env, opts.launch);
1160
+ return opened;
1161
+ },
1162
+ rename(exec, target, tier, name) {
1163
+ if (tier === "tab") {
1164
+ exec("wezterm", [
1165
+ "cli",
1166
+ "set-tab-title",
1167
+ "--tab-id",
1168
+ target.id,
1169
+ name
1170
+ ]);
1171
+ return;
1172
+ }
1173
+ throw new Error(`wezterm cannot name a pane (only a tab or window) — asked to rename ${target.id}`);
1174
+ },
1175
+ group() {},
1176
+ sendText(exec, target, text) {
1177
+ exec("wezterm", [
1178
+ "cli",
1179
+ "send-text",
1180
+ "--pane-id",
1181
+ target.id,
1182
+ text
1183
+ ]);
1184
+ },
1185
+ sendKeys(exec, target, keys) {
1186
+ const bytes = keys.map((k) => WEZTERM_KEY_BYTES[k] ?? k).join("");
1187
+ exec("wezterm", [
1188
+ "cli",
1189
+ "send-text",
1190
+ "--pane-id",
1191
+ target.id,
1192
+ "--no-paste",
1193
+ bytes
1194
+ ]);
1195
+ },
1196
+ submit(exec, target, text) {
1197
+ if (!text) {
1198
+ adapter.sendKeys(exec, target, ["Enter"]);
1199
+ return;
1200
+ }
1201
+ adapter.sendText(exec, target, text);
1202
+ adapter.sendKeys(exec, target, ["Enter"]);
1203
+ },
1204
+ read(exec, target, opts) {
1205
+ const args = [
1206
+ "cli",
1207
+ "get-text",
1208
+ "--pane-id",
1209
+ target.id
1210
+ ];
1211
+ if (opts?.lines != null) args.push("--start-line", String(-opts.lines));
1212
+ return exec("wezterm", args) ?? "";
1213
+ },
1214
+ focus(exec, target) {
1215
+ exec("wezterm", [
1216
+ "cli",
1217
+ "activate-pane",
1218
+ "--pane-id",
1219
+ target.id
1220
+ ]);
1221
+ },
1222
+ teardown(exec, target) {
1223
+ exec("wezterm", [
1224
+ "cli",
1225
+ "kill-pane",
1226
+ "--pane-id",
1227
+ target.id
1228
+ ]);
1229
+ },
1230
+ paneExists(exec, target) {
1231
+ return listWeztermPanes(exec).some((p) => String(p.pane_id) === target.id);
1232
+ },
1233
+ isPaneFocused() {},
1234
+ listPanes(exec) {
1235
+ return listWeztermPanes(exec).map((p) => {
1236
+ const pane = {
1237
+ id: String(p.pane_id),
1238
+ mux: "wezterm"
1239
+ };
1240
+ const cwd = weztermCwd(p.cwd);
1241
+ if (cwd) pane.cwd = cwd;
1242
+ return pane;
1243
+ });
1244
+ }
1245
+ };
1246
+ return adapter;
1247
+ }
1248
+ const weztermMuxAdapter = createWeztermAdapter({ newId: nodeNewId });
1249
+ /**
1250
+ * `wezterm cli spawn`/`split-pane` report ONLY the new pane's bare id on stdout — unlike tmux/herdr,
1251
+ * neither embeds the tab (or workspace) in that same output. So the tab this pane landed in is a
1252
+ * follow-up `list --format json` lookup rather than a free read of an envelope already held.
1253
+ * `OpenedPane.tab` is still required — every multiplexer has the Tab level — it simply costs more
1254
+ * here than the "no extra call" property tmux/herdr get to claim.
1255
+ */
1256
+ function resolveTab(exec, pane) {
1257
+ const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
1258
+ if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
1259
+ return String(found.tab_id);
1260
+ }
1261
+ /**
1262
+ * A window of the named workspace — what `spawn --window-id` needs to place a tab in a workspace
1263
+ * WezTerm's CLI cannot target directly (`--workspace` only names the space a `--new-window` spawn
1264
+ * creates, so it cannot place a tab in an existing one).
1265
+ *
1266
+ * Throws rather than falling back to an untargeted spawn: an unresolvable anchor means the workspace
1267
+ * is gone, and the untargeted spawn is exactly the wrong-space bug `within` was added to close.
1268
+ */
1269
+ function resolveWorkspaceWindow(exec, workspace) {
1270
+ const found = listWeztermPanes(exec).find((p) => p.workspace === workspace);
1271
+ if (!found) throw new Error(`wezterm reported no window in workspace ${workspace} to open a tab in`);
1272
+ return String(found.window_id);
1273
+ }
1274
+ /** `resolveTab` plus the workspace the same lookup already answers — one call serves both facts. */
1275
+ function withTabAndWorkspace(exec, pane) {
1276
+ const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
1277
+ if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
1278
+ return {
1279
+ id: pane,
1280
+ tab: String(found.tab_id),
1281
+ workspace: found.workspace
1282
+ };
1283
+ }
1284
+ /**
1285
+ * Env is native at NO tier on this backend — `spawn`/`split-pane` take no `--env` at all, unlike
1286
+ * herdr (native everywhere but one worktree route). So every `open` funnels through the same
1287
+ * fallback herdr's worktree route uses: with a launch command, env rides in as an `env K=V` prefix;
1288
+ * with none, a warning names what did not land. `envFallback` is a no-op when there is no env to
1289
+ * carry, so this is safe to call unconditionally.
1290
+ */
1291
+ function runLaunch$1(adapter, exec, target, env, launch) {
1292
+ const fallback = envFallback(env, launch);
1293
+ if (fallback.kind === "dropped") {
1294
+ process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this wezterm pane — wezterm has no --env flag on any space-creating command
1295
+ `);
1296
+ return;
1297
+ }
1298
+ if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
1299
+ }
1300
+ /** One `wezterm cli list --format json` call, parsed defensively — never throws on bad output. */
1301
+ function listWeztermPanes(exec) {
1302
+ const out = exec("wezterm", [
1303
+ "cli",
1304
+ "list",
1305
+ "--format",
1306
+ "json"
1307
+ ]);
1308
+ if (!out) return [];
1309
+ let parsed;
1310
+ try {
1311
+ parsed = JSON.parse(out);
1312
+ } catch {
1313
+ return [];
1314
+ }
1315
+ if (!Array.isArray(parsed)) return [];
1316
+ return parsed.filter((p) => p != null && p.pane_id != null && p.tab_id != null && p.window_id != null);
1317
+ }
1318
+ /** `cwd` is reported as a `file://` URI; strip the scheme and host down to the bare path. */
1319
+ function weztermCwd(cwd) {
1320
+ if (!cwd) return void 0;
1321
+ const match = /^file:\/\/[^/]*(\/.*)$/.exec(cwd);
1322
+ return match ? decodeURIComponent(match[1]) : cwd;
1323
+ }
1324
+ /**
1325
+ * `ratio` is the fraction kept by the ORIGINAL pane; `--percent` sizes the NEW one (the issue's own
1326
+ * probe note, #47) — the same inversion tmux's `-l` needs, unlike herdr's pass-through.
1327
+ */
1328
+ function toWeztermSize(ratio) {
1329
+ return String(Math.round((1 - ratio) * 100));
1330
+ }
1331
+ /**
1332
+ * The core vocabulary, realized as raw terminal bytes rather than a backend key NAME — there is no
1333
+ * send-keys-shaped verb to name a key TO, only `send-text`. Escape sequences are the ANSI/VT100
1334
+ * "cursor key mode" forms every common shell/program already parses; `Backspace` sends DEL (`\x7f`),
1335
+ * what most terminals emit for that key today (probed, not read off any wezterm spec — wezterm ships
1336
+ * no such table because it has no key-name CLI surface to spec).
1337
+ *
1338
+ * `Home`/`End`/`Delete`/`Insert`/`PageUp`/`PageDown` are extras beyond the core, included for the
1339
+ * same reason tmux "knows" `Home` even though the core vocabulary does not name it: these are
1340
+ * standard-enough ANSI keys that encoding them costs nothing extra and a caller reaching for one
1341
+ * should not silently get the literal word typed instead.
1342
+ */
1343
+ const WEZTERM_KEY_BYTES = {
1344
+ Up: "\x1B[A",
1345
+ Down: "\x1B[B",
1346
+ Right: "\x1B[C",
1347
+ Left: "\x1B[D",
1348
+ Enter: "\r",
1349
+ Escape: "\x1B",
1350
+ Tab: " ",
1351
+ Space: " ",
1352
+ Backspace: "",
1353
+ "C-c": "",
1354
+ F1: "\x1BOP",
1355
+ F2: "\x1BOQ",
1356
+ F3: "\x1BOR",
1357
+ F4: "\x1BOS",
1358
+ F5: "\x1B[15~",
1359
+ F6: "\x1B[17~",
1360
+ F7: "\x1B[18~",
1361
+ F8: "\x1B[19~",
1362
+ F9: "\x1B[20~",
1363
+ F10: "\x1B[21~",
1364
+ F11: "\x1B[23~",
1365
+ F12: "\x1B[24~",
1366
+ Home: "\x1B[H",
1367
+ End: "\x1B[F",
1368
+ Delete: "\x1B[3~",
1369
+ Insert: "\x1B[2~",
1370
+ PageUp: "\x1B[5~",
1371
+ PageDown: "\x1B[6~"
1372
+ };
1373
+ //#endregion
1374
+ //#region src/mux.zellij.ts
1375
+ /**
1376
+ * Zellij backend — detected via `$ZELLIJ`. Drives Zellij's built-in multiplexer through `zellij
1377
+ * action …` (https://zellij.dev/documentation/cli-actions), the same synchronous-CLI shape tmux,
1378
+ * herdr and wezterm already give `Exec`.
1379
+ *
1380
+ * Requires **Zellij ≥ 0.44.1**. The whole per-pane-addressable surface this adapter stands on landed
1381
+ * in 0.44.0 (2026-03-23): `--pane-id` across the action verbs, `list-panes --json`, ids returned from
1382
+ * `new-pane`/`new-tab`, and `focus-pane-id`. Before that release Zellij's CLI was almost entirely
1383
+ * FOCUS-relative — no stable per-pane handle — and no faithful adapter was possible. On an older
1384
+ * binary these commands fail and the adapter surfaces the failure rather than silently mis-targeting
1385
+ * the focused pane.
1386
+ *
1387
+ * Probed from the Zellij docs + CHANGELOG only — Zellij is not installed in this sandbox, so nothing
1388
+ * here carries the "verified against a live binary" claim `session.tmux.ts`/`session.herdr.ts` make;
1389
+ * it makes the same honest disclaimer `mux.wezterm.ts` does. Two literals in particular are worth a
1390
+ * one-line confirmation on a live 0.44.1 binary — the exact form `new-pane` prints an id in (bare `3`
1391
+ * vs `terminal_3`) and the shell value of `$ZELLIJ_PANE_ID`. Both are handled either way: ids are
1392
+ * carried verbatim and compared through `samePane`, which treats a bare `N` and its `terminal_N` twin
1393
+ * as the same pane (per the docs' own `terminal_N | plugin_N | bare N` scheme).
1394
+ *
1395
+ * Real capability shape that fell out of the probe:
1396
+ *
1397
+ * - **The session is the workspace tier, but a SEPARATE session's panes are un-addressable through
1398
+ * this seam.** Zellij's native tiers are Session › Tab › Pane, so a `workspace` placement would
1399
+ * naturally open a new session (`zellij attach --create-background`). But Zellij pane ids are
1400
+ * SESSION-SCOPED — driving another session's pane requires `zellij --session <name> action …` — and
1401
+ * `MuxTarget` carries only an opaque pane id, no session. A pane returned from a freshly-created
1402
+ * session would therefore fail on the next `write`/`read`/`focus`, which is a trap, not a feature.
1403
+ * So this adapter operates within the AMBIENT session: `workspace` collapses to a new **tab**, the
1404
+ * same collapse tmux makes for a Window. Unlike tmux, the occupied workspace IS reported —
1405
+ * `OpenedPane.workspace` carries the ambient session name (`deps.session`), because every pane
1406
+ * genuinely lives in that session. (Lifting the collapse needs a seam change: a session qualifier
1407
+ * on `MuxTarget`. Recorded in the ADR log.)
1408
+ * - **No `--env` on `new-pane`/`new-tab`.** Like wezterm, env is native at no tier, so every open
1409
+ * rides the `envFallback` compensation (an `env K=V` prefix on the launch command, or a stderr
1410
+ * warning when there is no command to ride).
1411
+ * - **Tiled splits cannot be sized.** `new-pane`'s `-x/-y/--width/--height` all require `--floating`;
1412
+ * a tiled `pane:*` split is always even. So `canSizeSplits` is omitted and a `ratio` is dropped —
1413
+ * callers degrade to the even default, the same path the flag's absence already documents.
1414
+ * - **`new-pane` has no split-TARGET flag.** It splits the focused pane (or the biggest space); the
1415
+ * only flag is `--tab-id`. So `from` — which pane a `pane:*` split lands beside — is honored by
1416
+ * FOCUSING that pane first, the sole way to choose the split target. That is a real focus move, and
1417
+ * the honest cost of getting the RIGHT pane split.
1418
+ * - **No pane geometry adapter.** `list-panes --json` does report `pane_x`/`pane_y`, so `regions`
1419
+ * (`describeRegion`/`describeWorkspace`) is IMPLEMENTABLE here — unlike wezterm, which lacks
1420
+ * position entirely — but the cell-vs-divider semantics of Zellij's rects need a live binary to pin,
1421
+ * so it is deliberately left as a follow-up rather than guessed. `template save` refuses on zellij
1422
+ * by naming the backend, the same optional-absence it already handles for wezterm.
1423
+ * - **No git-worktree concept in the CLI.** No `worktree` subcommand, so — like tmux and wezterm —
1424
+ * this backend never binds one to a workspace; callers fall back to plain git plus `open()`.
1425
+ *
1426
+ * Wins Zellij has that wezterm does not: it CAN name a pane (`new-pane --name` / `rename-pane`), and
1427
+ * it CAN report which pane is focused (`list-panes --json`'s `is_focused`), so `isPaneFocused`
1428
+ * answers a real value rather than always `unknown`.
1429
+ */
1430
+ /**
1431
+ * Build the Zellij adapter over its injected effects. The only effect it takes is `session` — the
1432
+ * ambient Zellij session name (`$ZELLIJ_SESSION_NAME`), reported as `OpenedPane.workspace` so a
1433
+ * caller learns which workspace a new pane landed in. Undefined when unknown (the exported singleton),
1434
+ * in which case `workspace` is simply omitted — the same absent-not-false convention the seam uses
1435
+ * elsewhere. Exported as a factory (with `zellijMuxAdapter` binding the effect-less one) so
1436
+ * `backend.ts` can bind the real session name off `env` at resolution, exactly the seam `Exec` and
1437
+ * wezterm's `newId` already are.
1438
+ */
1439
+ function createZellijAdapter(deps) {
1440
+ const adapter = {
1441
+ name: "zellij",
1442
+ open(exec, opts) {
1443
+ const at = opts.at ?? "tab";
1444
+ if (at === "tab" || at === "workspace") {
1445
+ const args = [
1446
+ "action",
1447
+ "new-tab",
1448
+ "--cwd",
1449
+ opts.cwd
1450
+ ];
1451
+ if (opts.label) args.push("--name", opts.label);
1452
+ const out = exec("zellij", args);
1453
+ if (!out) throw new Error(withReason(exec, "zellij action new-tab failed"));
1454
+ const tabId = out.trim();
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);
1457
+ runLaunch(adapter, exec, opened, opts.env, opts.launch);
1458
+ return opened;
1459
+ }
1460
+ if (opts.from) adapter.focus(exec, opts.from);
1461
+ const args = [
1462
+ "action",
1463
+ "new-pane",
1464
+ "--direction",
1465
+ at === "pane:down" ? "down" : "right",
1466
+ "--cwd",
1467
+ opts.cwd
1468
+ ];
1469
+ if (opts.label) args.push("--name", opts.label);
1470
+ const out = exec("zellij", args);
1471
+ if (!out) throw new Error(withReason(exec, "zellij action new-pane failed"));
1472
+ const paneId = out.trim();
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);
1475
+ runLaunch(adapter, exec, opened, opts.env, opts.launch);
1476
+ return opened;
1477
+ },
1478
+ rename(exec, target, tier, name) {
1479
+ if (tier === "tab") {
1480
+ exec("zellij", [
1481
+ "action",
1482
+ "rename-tab-by-id",
1483
+ target.id,
1484
+ name
1485
+ ]);
1486
+ return;
1487
+ }
1488
+ exec("zellij", [
1489
+ "action",
1490
+ "rename-pane",
1491
+ "--pane-id",
1492
+ target.id,
1493
+ name
1494
+ ]);
1495
+ },
1496
+ group() {},
1497
+ sendText(exec, target, text) {
1498
+ exec("zellij", [
1499
+ "action",
1500
+ "write-chars",
1501
+ "--pane-id",
1502
+ target.id,
1503
+ text
1504
+ ]);
1505
+ },
1506
+ sendKeys(exec, target, keys) {
1507
+ exec("zellij", [
1508
+ "action",
1509
+ "send-keys",
1510
+ "--pane-id",
1511
+ target.id,
1512
+ ...keys.map(toZellijKey)
1513
+ ]);
1514
+ },
1515
+ submit(exec, target, text) {
1516
+ if (!text) {
1517
+ adapter.sendKeys(exec, target, ["Enter"]);
1518
+ return;
1519
+ }
1520
+ adapter.sendText(exec, target, text);
1521
+ adapter.sendKeys(exec, target, ["Enter"]);
1522
+ },
1523
+ read(exec, target, opts) {
1524
+ if (opts?.lines != null) return lastLines(exec("zellij", [
1525
+ "action",
1526
+ "dump-screen",
1527
+ "--pane-id",
1528
+ target.id,
1529
+ "--full"
1530
+ ]) ?? "", opts.lines);
1531
+ return exec("zellij", [
1532
+ "action",
1533
+ "dump-screen",
1534
+ "--pane-id",
1535
+ target.id
1536
+ ]) ?? "";
1537
+ },
1538
+ focus(exec, target) {
1539
+ exec("zellij", [
1540
+ "action",
1541
+ "focus-pane-id",
1542
+ target.id
1543
+ ]);
1544
+ },
1545
+ teardown(exec, target) {
1546
+ exec("zellij", [
1547
+ "action",
1548
+ "close-pane",
1549
+ "--pane-id",
1550
+ target.id
1551
+ ]);
1552
+ },
1553
+ paneExists(exec, target) {
1554
+ return listZellijPanes(exec).some((p) => samePane(p.id, target.id));
1555
+ },
1556
+ isPaneFocused(exec, target) {
1557
+ const found = listZellijPanes(exec).find((p) => samePane(p.id, target.id));
1558
+ if (!found) return void 0;
1559
+ return found.is_focused === true;
1560
+ },
1561
+ listPanes(exec) {
1562
+ return listZellijPanes(exec).map((p) => {
1563
+ const pane = {
1564
+ id: p.id,
1565
+ mux: "zellij"
1566
+ };
1567
+ if (p.pane_cwd) pane.cwd = p.pane_cwd;
1568
+ const label = zellijLabel(p.title, p.pane_command);
1569
+ if (label) pane.label = label;
1570
+ return pane;
1571
+ });
1572
+ }
1573
+ };
1574
+ return adapter;
1575
+ }
1576
+ const zellijMuxAdapter = createZellijAdapter({});
1577
+ /**
1578
+ * One `zellij action list-panes --json` call, parsed defensively — never throws on bad output. The id
1579
+ * is coerced to a string so a bare-integer id (`3`) and a prefixed one (`terminal_3`) are compared as
1580
+ * strings by `samePane` rather than one being a number.
1581
+ */
1582
+ function listZellijPanes(exec) {
1583
+ const out = exec("zellij", [
1584
+ "action",
1585
+ "list-panes",
1586
+ "--json"
1587
+ ]);
1588
+ if (!out) return [];
1589
+ let parsed;
1590
+ try {
1591
+ parsed = JSON.parse(out);
1592
+ } catch {
1593
+ return [];
1594
+ }
1595
+ if (!Array.isArray(parsed)) return [];
1596
+ return parsed.filter((p) => p != null && p.id != null).map((p) => ({
1597
+ ...p,
1598
+ id: String(p.id)
1599
+ }));
1600
+ }
1601
+ /**
1602
+ * The `OpenedPane` for a pane `new-pane` just reported — its tab resolved from the one `list-panes`
1603
+ * record carrying that pane id, and the workspace filled from the ambient session name. Throws rather
1604
+ * than guessing a tab: `OpenedPane.tab` is required (every multiplexer has the Tab level), and a wrong
1605
+ * tab is worse than a loud failure.
1606
+ */
1607
+ function openedForPane(exec, paneId, session) {
1608
+ const found = listZellijPanes(exec).find((p) => samePane(p.id, paneId));
1609
+ if (!found || found.tab_id == null) throw new Error(`zellij did not report a tab for the new pane ${paneId}`);
1610
+ return openedPane(paneId, String(found.tab_id), session);
1611
+ }
1612
+ /**
1613
+ * The `OpenedPane` for a tab `new-tab` just reported — its initial pane resolved as the single
1614
+ * `list-panes` record carrying that tab id. Throws rather than guessing: a new tab must have a pane,
1615
+ * and a caller handed a tab with no pane could neither drive nor name it.
1616
+ */
1617
+ function openedForTab(exec, tabId, session) {
1618
+ const pane = listZellijPanes(exec).find((p) => p.tab_id != null && String(p.tab_id) === tabId);
1619
+ if (!pane) throw new Error(`zellij did not report a pane in the new tab ${tabId}`);
1620
+ return openedPane(pane.id, tabId, session);
1621
+ }
1622
+ /**
1623
+ * Assemble an `OpenedPane`, attaching `workspace` only when the ambient session name is known — the
1624
+ * absent-not-false convention. Every Zellij pane genuinely lives in a session, so where the name IS
1625
+ * known this is never absent, on every placement including `pane:*`; where it is not (the effect-less
1626
+ * singleton), it is omitted rather than reported as a false "none".
1627
+ */
1628
+ function openedPane(id, tab, session) {
1629
+ const opened = {
1630
+ id,
1631
+ tab
1632
+ };
1633
+ if (session) opened.workspace = session;
1634
+ return opened;
1635
+ }
1636
+ /**
1637
+ * Env is native at NO tier on this backend — `new-pane`/`new-tab` take no `--env`, like wezterm. So
1638
+ * every `open` funnels through the same fallback herdr's worktree route uses: with a launch command,
1639
+ * env rides in as an `env K=V` prefix; with none, a warning names what did not land. `envFallback` is
1640
+ * a no-op when there is no env to carry, so this is safe to call unconditionally.
1641
+ */
1642
+ function runLaunch(adapter, exec, target, env, launch) {
1643
+ const fallback = envFallback(env, launch);
1644
+ if (fallback.kind === "dropped") {
1645
+ process.stderr.write(`env (${fallback.variables.join(", ")}) could not be set on this zellij pane — zellij has no --env flag on new-pane/new-tab
1646
+ `);
1647
+ return;
1648
+ }
1649
+ if (fallback.command !== void 0) adapter.submit(exec, target, fallback.command);
1650
+ }
1651
+ /**
1652
+ * The core key vocabulary's Zellij spelling — a rename table, NOT a validation table: a token outside
1653
+ * the core is forwarded verbatim (the seam's passthrough), so this must not reject what it does not
1654
+ * recognize. Only the two members that differ from the core name are listed; `Up` `Down` `Left`
1655
+ * `Right` `Enter` `Tab` `Space` `Backspace` `F1`–`F12` are already Zellij's own names. Probed from
1656
+ * Zellij's key-binding vocabulary in the docs, not verified against a live `send-keys`:
1657
+ *
1658
+ * - `Escape` → `Esc`: Zellij's key name for that key is `Esc`.
1659
+ * - `C-c` → `Ctrl c`: Zellij writes a modified key as space-separated words (`"Ctrl a"`, `"Alt Shift
1660
+ * b"`), so the whole `Ctrl c` is one argument — the modifier and key are not two keys.
1661
+ */
1662
+ const ZELLIJ_KEY_RENAMES = {
1663
+ Escape: "Esc",
1664
+ "C-c": "Ctrl c"
1665
+ };
1666
+ function toZellijKey(key) {
1667
+ return ZELLIJ_KEY_RENAMES[key] ?? key;
1668
+ }
1669
+ /**
1670
+ * 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 `pane_command`, so a title equal to it is
1672
+ * ambient rather than chosen; exporting it would put the same manufactured name on every shell pane,
1673
+ * exactly the collision tmux's hostname guard exists to prevent. A title that differs from the command
1674
+ * is one someone set (`new-pane --name`/`rename-pane`), so it is the author's and survives.
1675
+ */
1676
+ function zellijLabel(title, command) {
1677
+ return title && title !== command ? title : void 0;
1678
+ }
1679
+ /**
1680
+ * Normalize a pane id to a single canonical form for comparison. Per the Zellij docs a bare integer
1681
+ * `N` is the same terminal pane as `terminal_N`; `plugin_N` is a distinct space. So a bare id is
1682
+ * folded to its `terminal_` form and everything else is left as-is — a comparison-only transform,
1683
+ * never stored (`MuxTarget.id` stays whatever the backend reported).
1684
+ */
1685
+ function normalizePaneId(id) {
1686
+ return /^\d+$/.test(id) ? `terminal_${id}` : id;
1687
+ }
1688
+ /** Whether two pane ids name the same pane, treating a bare `N` and its `terminal_N` twin as equal. */
1689
+ function samePane(a, b) {
1690
+ return normalizePaneId(a) === normalizePaneId(b);
1691
+ }
1692
+ /** The last `n` lines of `text` — `read`'s client-side approximation of a trailing-lines capture. */
1693
+ function lastLines(text, n) {
1694
+ const lines = text.split("\n");
1695
+ return lines.slice(Math.max(0, lines.length - n)).join("\n");
1696
+ }
1697
+ //#endregion
1698
+ //#region src/mux-probe.ts
1699
+ const KNOWN_MUX = [
1700
+ "tmux",
1701
+ "herdr",
1702
+ "wezterm",
1703
+ "zellij",
1704
+ "screen",
1705
+ "none"
1706
+ ];
1707
+ function isKnownMux(v) {
1708
+ return v != null && KNOWN_MUX.includes(v);
1709
+ }
1710
+ /**
1711
+ * The single source of the mux → per-pane-env-var mapping. tmux exports `$TMUX_PANE`; herdr exports
1712
+ * `$HERDR_PANE_ID` (both in the same `wX:pY`-style namespace); WezTerm exports `$WEZTERM_PANE` in
1713
+ * every pane (its own bare-integer id) — per the issue that requested that backend (#47), the same
1714
+ * fast-path extension `$TMUX_PANE`/`$HERDR_PANE_ID` already get; Zellij exports `$ZELLIJ_PANE_ID` in
1715
+ * every terminal pane (its own `terminal_N`/bare-`N` id) — per the issue that requested this backend
1716
+ * (#46). screen carries no per-pane env var. Both the ancestry probe and the `currentPane`
1717
+ * self-identity helper read the pane through this table so the two never diverge on which env var a
1718
+ * given mux uses.
1719
+ */
1720
+ const PANE_ENV = {
1721
+ tmux: (env) => env["TMUX_PANE"],
1722
+ herdr: (env) => env["HERDR_PANE_ID"],
1723
+ wezterm: (env) => env["WEZTERM_PANE"],
1724
+ zellij: (env) => env["ZELLIJ_PANE_ID"]
1725
+ };
1726
+ /**
1727
+ * Resolve THIS session's own pane from env alone (no `ps` walk): the `$CYBER_MUX_PANE` fast-path a
1728
+ * spawn propagates → `$TMUX_PANE` (tmux) → `$HERDR_PANE_ID` (herdr) → `$WEZTERM_PANE` (wezterm) →
1729
+ * `$ZELLIJ_PANE_ID` (zellij). Returns the pane tagged with its multiplexer, or undefined when the
1730
+ * session is in no pane-carrying multiplexer. This is the mux-agnostic self-identity key.
1731
+ */
1732
+ function currentPane(env) {
1733
+ if (env["CYBER_MUX_PANE"]) return {
1734
+ mux: env["CYBER_MUX"] === "herdr" ? "herdr" : env["CYBER_MUX"] === "wezterm" ? "wezterm" : env["CYBER_MUX"] === "zellij" ? "zellij" : "tmux",
1735
+ pane: env["CYBER_MUX_PANE"]
1736
+ };
1737
+ const tmux = PANE_ENV.tmux(env);
1738
+ if (tmux) return {
1739
+ mux: "tmux",
1740
+ pane: tmux
1741
+ };
1742
+ const herdr = PANE_ENV.herdr(env);
1743
+ if (herdr) return {
1744
+ mux: "herdr",
1745
+ pane: herdr
1746
+ };
1747
+ const wezterm = PANE_ENV.wezterm(env);
1748
+ if (wezterm) return {
1749
+ mux: "wezterm",
1750
+ pane: wezterm
1751
+ };
1752
+ const zellij = PANE_ENV.zellij(env);
1753
+ if (zellij) return {
1754
+ mux: "zellij",
1755
+ pane: zellij
1756
+ };
1757
+ }
1758
+ /**
1759
+ * Two-mode multiplexer detection.
1760
+ *
1761
+ * Fast-path: `$CYBER_MUX` (tmux | herdr | wezterm | zellij | screen | none) is trusted outright —
1762
+ * this also serves as an OVERRIDE (`=none` forces no-mux even inside a real multiplexer).
1763
+ * `$CYBER_MUX_PANE` carries the pane id alongside it. Detection RECOGNIZES `screen` (so an override
1764
+ * pinning it, or a real screen ancestor, is reported truthfully rather than silently ignored), but
1765
+ * `screen` is not a drivable backend — `resolveMuxAdapter` rejects it with a reason. Recognition is
1766
+ * not support.
1767
+ *
1768
+ * Discovery (else): walk the process ancestry from `$$` via `ps -o ppid=,comm= -p <pid>`, since the
1769
+ * tool's own shell may not be the human's pane. `$TMUX`/`$HERDR_ENV` are NOT trusted alone — they
1770
+ * are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
1771
+ * inconclusive (e.g. `ps` unavailable), never as a substitute for it.
1772
+ */
1773
+ function probeMultiplexer(exec, env, opts = {}) {
1774
+ const prefix = opts.envPrefix ?? "CYBER_MUX";
1775
+ const override = env[prefix];
1776
+ const pane = env[`${prefix}_PANE`];
1777
+ if (isKnownMux(override)) return {
1778
+ mux: override,
1779
+ ...pane ? { pane } : {},
1780
+ via: "env"
1781
+ };
1782
+ if (opts.discover === false) return {
1783
+ mux: "none",
1784
+ via: "ancestry"
1785
+ };
1786
+ return discoverByAncestry(exec, env);
1787
+ }
1788
+ const MUX_COMM = [
1789
+ {
1790
+ re: /^tmux(:|$)/,
1791
+ mux: "tmux"
1792
+ },
1793
+ {
1794
+ re: /^herdr(:|$)/,
1795
+ mux: "herdr"
1796
+ },
1797
+ {
1798
+ re: /^wezterm(-gui|-mux-server)?(:|$)/,
1799
+ mux: "wezterm"
1800
+ },
1801
+ {
1802
+ re: /^zellij(:|$)/,
1803
+ mux: "zellij"
1804
+ },
1805
+ {
1806
+ re: /^screen(:|$)/,
1807
+ mux: "screen"
1808
+ }
1809
+ ];
1810
+ /** The per-pane env var for a mux, via the shared `PANE_ENV` table; undefined for screen/none. */
1811
+ function paneFor(mux, env) {
1812
+ return mux === "tmux" || mux === "herdr" || mux === "wezterm" || mux === "zellij" ? PANE_ENV[mux](env) : void 0;
1813
+ }
1814
+ /**
1815
+ * An ancestry-discovered probe, OMITTING `pane` when the mux carries none — never carrying it as an
1816
+ * explicit `undefined`, so `MuxProbe.pane` stays an absent-or-present field (the same conditional
1817
+ * shape the `$CYBER_MUX_PANE` fast-path uses above).
1818
+ */
1819
+ function ancestryProbe(mux, env) {
1820
+ const pane = paneFor(mux, env);
1821
+ return {
1822
+ mux,
1823
+ ...pane !== void 0 ? { pane } : {},
1824
+ via: "ancestry"
1825
+ };
1826
+ }
1827
+ const MAX_ANCESTORS = 32;
1828
+ function walkAncestry(exec, env) {
1829
+ let pid = process.pid;
1830
+ const seen = /* @__PURE__ */ new Set();
1831
+ for (let i = 0; i < MAX_ANCESTORS; i++) {
1832
+ if (seen.has(pid)) break;
1833
+ seen.add(pid);
1834
+ const line = exec("ps", [
1835
+ "-o",
1836
+ "ppid=,comm=",
1837
+ "-p",
1838
+ String(pid)
1839
+ ]);
1840
+ if (!line) break;
1841
+ const trimmed = line.trim();
1842
+ const spaceIdx = trimmed.indexOf(" ");
1843
+ const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
1844
+ const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
1845
+ const ppid = Number.parseInt(ppidStr, 10);
1846
+ for (const entry of MUX_COMM) if (entry.re.test(comm)) return ancestryProbe(entry.mux, env);
1847
+ if (!Number.isFinite(ppid) || ppid <= 1) break;
1848
+ pid = ppid;
1849
+ }
1850
+ }
1851
+ function discoverByAncestry(exec, env) {
1852
+ const found = walkAncestry(exec, env);
1853
+ if (found) return found;
1854
+ if (env["TMUX"]) return ancestryProbe("tmux", env);
1855
+ if (env["HERDR_ENV"]) return ancestryProbe("herdr", env);
1856
+ if (env["WEZTERM_PANE"]) return ancestryProbe("wezterm", env);
1857
+ if (env["ZELLIJ"]) return ancestryProbe("zellij", env);
1858
+ return {
1859
+ mux: "none",
1860
+ via: "ancestry"
1861
+ };
1862
+ }
1863
+ //#endregion
1864
+ //#region src/nudge.ts
1865
+ /** Max flush re-submits after the initial submit, before nudge fails loud. */
1866
+ const DEFAULT_ATTEMPTS = 10;
1867
+ /** Wait after a submit before reading the pane back, in ms. */
1868
+ const DEFAULT_SETTLE_MS = 400;
1869
+ /** Length of the message prefix used as the staged-text needle. */
1870
+ const NEEDLE_LEN = 40;
1871
+ /**
1872
+ * Whether `message` is still sitting staged in `visible`'s input box (not yet submitted). A real
1873
+ * submit scrolls the message into the transcript, leaving the bottom input box empty, so the
1874
+ * needle is absent from the tail; while staged it sits in the bottom input box, so the needle is
1875
+ * present. `visible` being null/empty means the turn can't be confirmed — treated as still staged
1876
+ * so callers keep retrying rather than falsely reporting success.
1877
+ */
1878
+ function isStaged(visible, message) {
1879
+ if (!visible) return true;
1880
+ const needle = message.replace(/\s+/g, " ").trim().slice(0, NEEDLE_LEN);
1881
+ if (needle === "") return true;
1882
+ return visible.split("\n").filter((l) => l.trim() !== "").slice(-5).join(" ").replace(/\s+/g, " ").trim().includes(needle);
1883
+ }
1884
+ /**
1885
+ * Submit `message` to `target` and verify the peer actually took the turn — a booting harness can
1886
+ * swallow the Enter of the initial `submit`, leaving the text staged unsent while nudge would
1887
+ * otherwise report false success. Types the message exactly once; a swallowed Enter is recovered by
1888
+ * flushing the staged buffer (`adapter.submit` with no text, a bare Enter) — never re-typing the
1889
+ * message — up to a bounded number of attempts. Throws if the turn is never taken within the cap.
1890
+ *
1891
+ * A pane that no longer exists is rejected up front rather than retried: a gone pane and a booting
1892
+ * one both read back empty, so without the liveness probe the retry loop reports a dead peer as
1893
+ * "never took the turn" — a boot-race shape — and buries the real cause.
1894
+ */
1895
+ async function nudge(adapter, exec, target, message, opts = {}) {
1896
+ const attempts = opts.attempts ?? DEFAULT_ATTEMPTS;
1897
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
1898
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1899
+ 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
+ adapter.submit(exec, target, message);
1901
+ await sleep(settleMs);
1902
+ if (!isStaged(adapter.read(exec, target), message)) return {
1903
+ taken: true,
1904
+ resubmits: 0
1905
+ };
1906
+ for (let attempt = 1; attempt <= attempts; attempt++) {
1907
+ adapter.submit(exec, target);
1908
+ await sleep(settleMs);
1909
+ if (!isStaged(adapter.read(exec, target), message)) return {
1910
+ taken: true,
1911
+ resubmits: attempt
1912
+ };
1913
+ }
1914
+ throw new Error(`nudge failed: peer at pane ${target.id} never took the turn — input still staged after ${attempts} re-submit attempts`);
1915
+ }
1916
+ //#endregion
1917
+ //#region src/backend.ts
1918
+ /**
1919
+ * Resolve the raw backend for the multiplexer this process is inside, via the two-mode mux probe
1920
+ * (`$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 inconclusive) —
1922
+ * tmux/herdr/wezterm/zellij map to their adapters; anything else throws, because a caller asking to
1923
+ * drive panes with no multiplexer has an unmet precondition, and a loud, actionable failure beats a
1924
+ * silent no-op.
1925
+ *
1926
+ * The zellij adapter is bound to the ambient session name (`$ZELLIJ_SESSION_NAME`) at resolution — it
1927
+ * 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`.
1929
+ *
1930
+ * `screen` is a KNOWN mux — the probe detects it (ancestry) and honors it as an override (fast-path)
1931
+ * — but it is NOT a drivable backend, so it throws its OWN message naming the reason rather than the
1932
+ * generic "no multiplexer" one. That distinction is the whole point of issue #45: a caller who
1933
+ * pinned `CYBER_MUX=screen` declared a real multiplexer, and telling them "run inside a multiplexer"
1934
+ * would be a lie. The empirical probe behind the rejection is recorded in the ADR log
1935
+ * (`.agents/spec/design/decisions`): GNU Screen addresses its split regions POSITIONALLY (no
1936
+ * per-pane id), and `$WINDOW` is left unset in panes opened via `screen -X`, so a driver-created
1937
+ * pane has no stable identity to send to, read from, self-identify by, or enumerate — the exact
1938
+ * affordance `SessionTarget.id`/`currentPane`/`LivePane.id` are load-bearing on, and the exact one
1939
+ * wezterm (#47) had and screen lacks. Keeping the value KNOWN-but-rejected honors the override's
1940
+ * whole reason to exist: pinning it tells the caller the truth immediately instead of silently
1941
+ * discovering something else.
1942
+ *
1943
+ * Returns the PURE, exec-injected `MuxAdapter` seam — the one whose methods each take an `Exec`.
1944
+ * `resolveMux` binds one of these into a `MuxSession`; this is the lower layer, exported for a caller
1945
+ * that wants to thread its own runner per call rather than bind one. Not in a multiplexer? Gate on
1946
+ * `probeMultiplexer(env).mux !== 'none'` first — that answer never throws.
1947
+ */
1948
+ function resolveMuxAdapter(env, exec = nodeExec) {
1949
+ const probe = probeMultiplexer(exec, env);
1950
+ if (probe.mux === "tmux") return tmuxMuxAdapter;
1951
+ if (probe.mux === "herdr") return herdrMuxAdapter;
1952
+ if (probe.mux === "wezterm") return weztermMuxAdapter;
1953
+ if (probe.mux === "zellij") return createZellijAdapter({ session: env["ZELLIJ_SESSION_NAME"] });
1954
+ if (probe.mux === "screen") throw new Error("cyber-mux detected GNU Screen, which it cannot drive: Screen addresses its split regions positionally (no per-pane id) and leaves $WINDOW unset in panes opened via `screen -X`, so a pane has no stable identity to send to, read from, or self-identify by. Run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), or zellij ($ZELLIJ set) instead.");
1955
+ throw new Error("cyber-mux requires a session backend — run inside tmux ($TMUX), herdr ($HERDR_ENV=1), wezterm ($WEZTERM_PANE set), or zellij ($ZELLIJ set)");
1956
+ }
1957
+ /**
1958
+ * Resolve the multiplexer this process is inside and return it as a `MuxSession` with `exec` BOUND —
1959
+ * the ergonomic entry point. `deps.exec` (default `nodeExec`) runs the detection probe AND is the
1960
+ * default runner every method binds; a per-call `deps` overrides it.
1961
+ *
1962
+ * Throws when no supported multiplexer is detected (see `resolveMuxAdapter`); gate on
1963
+ * `probeMultiplexer(env).mux !== 'none'` first if a caller runs with-or-without one. For the pure
1964
+ * exec-injected seam, reach for `resolveMuxAdapter` instead.
1965
+ */
1966
+ function resolveMux(env, deps) {
1967
+ const boundExec = deps?.exec ?? nodeExec;
1968
+ const raw = resolveMuxAdapter(env, boundExec);
1969
+ const pick = (d) => d?.exec ?? boundExec;
1970
+ return {
1971
+ name: raw.name,
1972
+ ...raw.canSizeSplits !== void 0 ? { canSizeSplits: raw.canSizeSplits } : {},
1973
+ callerPane: () => callerPane(raw, env),
1974
+ open: (opts, d) => raw.open(pick(d), opts),
1975
+ rename: (target, tier, name, d) => raw.rename(pick(d), target, tier, name),
1976
+ group: (target, group, name, d) => raw.group(pick(d), target, group, name),
1977
+ sendText: (target, text, d) => raw.sendText(pick(d), target, text),
1978
+ sendKeys: (target, keys, d) => raw.sendKeys(pick(d), target, keys),
1979
+ submit: (target, text, d) => raw.submit(pick(d), target, text),
1980
+ read: (target, opts, d) => raw.read(pick(d), target, opts),
1981
+ focus: (target, d) => raw.focus(pick(d), target),
1982
+ teardown: (target, d) => raw.teardown(pick(d), target),
1983
+ paneExists: (target, d) => raw.paneExists(pick(d), target),
1984
+ isPaneFocused: (target, d) => raw.isPaneFocused(pick(d), target),
1985
+ listPanes: (d) => raw.listPanes(pick(d)),
1986
+ nudge: (target, message, opts, d) => nudge(raw, pick(d), target, message, opts),
1987
+ ...raw.worktree ? { worktree: bindWorktree(raw.worktree, pick) } : {},
1988
+ ...raw.regions ? { regions: bindRegions(raw.regions, pick) } : {}
1989
+ };
1990
+ }
1991
+ function bindWorktree(wt, pick) {
1992
+ return {
1993
+ createInWorkspace: (opts, d) => wt.createInWorkspace(pick(d), opts),
1994
+ openInWorkspace: (opts, d) => wt.openInWorkspace(pick(d), opts),
1995
+ bindings: (opts, d) => wt.bindings(pick(d), opts),
1996
+ releaseWorkspace: (workspace, d) => wt.releaseWorkspace(pick(d), workspace)
1997
+ };
1998
+ }
1999
+ function bindRegions(regions, pick) {
2000
+ return {
2001
+ describeRegion: (target, d) => regions.describeRegion(pick(d), target),
2002
+ describeWorkspace: (target, d) => regions.describeWorkspace(pick(d), target)
2003
+ };
2004
+ }
2005
+ /**
2006
+ * This process's own pane, as something `adapter` can address — `MuxOpenOptions.from`'s intended
2007
+ * argument for a `pane:*` open, so a split lands on the caller rather than on whichever pane the
2008
+ * user is looking at (see `from`'s note for why each backend's default gets that wrong).
2009
+ *
2010
+ * `undefined` when this session is in no pane, or in a pane belonging to a *different* multiplexer
2011
+ * than `adapter` drives — that mismatch is reachable (a `$TMUX_PANE` inherited into a herdr pane,
2012
+ * `$CYBER_MUX` overridden to the other backend), and handing one backend the other's pane id would
2013
+ * turn a self-identity mixup into a split of some unrelated pane. Falling back to the backend's own
2014
+ * default is the conservative answer: still possibly the wrong pane, but never a foreign id.
2015
+ *
2016
+ * A `MuxSession` exposes this bound as `mux.callerPane()`; this free form is for the raw seam.
2017
+ */
2018
+ function callerPane(adapter, env) {
2019
+ const self = currentPane(env);
2020
+ return self && self.mux === adapter.name ? { id: self.pane } : void 0;
2021
+ }
2022
+ //#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 };
2024
+
2025
+ //# sourceMappingURL=backend-DnrbmL6N.mjs.map