cyber-mux 0.1.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/cli.mjs ADDED
@@ -0,0 +1,3716 @@
1
+ import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
2
+ import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
3
+ import { execFileSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ import { homedir } from "node:os";
7
+ //#region src/exec.ts
8
+ const realExec = (cmd, args) => {
9
+ try {
10
+ const out = execFileSync(cmd, args, {
11
+ encoding: "utf8",
12
+ stdio: [
13
+ "ignore",
14
+ "pipe",
15
+ "pipe"
16
+ ]
17
+ }).trim();
18
+ realExec.lastError = void 0;
19
+ return out;
20
+ } catch (err) {
21
+ const stderr = err.stderr;
22
+ realExec.lastError = String(stderr ?? "").trim() || void 0;
23
+ return null;
24
+ }
25
+ };
26
+ /**
27
+ * A failure message carrying the runner's reason for it, when there is one. The backend's own words
28
+ * verbatim — never a paraphrase, and never a guess: a refused split may be a region too small, or a
29
+ * server that is simply gone, and only the backend knows which.
30
+ */
31
+ function withReason(exec, message) {
32
+ return exec.lastError ? `${message} — ${exec.lastError}` : message;
33
+ }
34
+ //#endregion
35
+ //#region src/mux-probe.ts
36
+ const KNOWN_MUX = [
37
+ "tmux",
38
+ "herdr",
39
+ "wezterm",
40
+ "screen",
41
+ "none"
42
+ ];
43
+ function isKnownMux(v) {
44
+ return v != null && KNOWN_MUX.includes(v);
45
+ }
46
+ /**
47
+ * The single source of the mux → per-pane-env-var mapping. tmux exports `$TMUX_PANE`; herdr exports
48
+ * `$HERDR_PANE_ID` (both in the same `wX:pY`-style namespace); WezTerm exports `$WEZTERM_PANE` in
49
+ * every pane (its own bare-integer id) — per the issue that requested this backend (#47), the same
50
+ * fast-path extension `$TMUX_PANE`/`$HERDR_PANE_ID` already get. screen carries no per-pane env var.
51
+ * Both the ancestry probe and the `currentPane` self-identity helper read the pane through this
52
+ * table so the two never diverge on which env var a given mux uses.
53
+ */
54
+ const PANE_ENV = {
55
+ tmux: (env) => env.TMUX_PANE,
56
+ herdr: (env) => env.HERDR_PANE_ID,
57
+ wezterm: (env) => env.WEZTERM_PANE
58
+ };
59
+ /**
60
+ * Resolve THIS session's own pane from env alone (no `ps` walk): the `$CYBER_MUX_PANE` fast-path a
61
+ * spawn propagates → `$TMUX_PANE` (tmux) → `$HERDR_PANE_ID` (herdr) → `$WEZTERM_PANE` (wezterm).
62
+ * Returns the pane tagged with its multiplexer, or undefined when the session is in no
63
+ * pane-carrying multiplexer. This is the mux-agnostic self-identity key.
64
+ */
65
+ function currentPane(env) {
66
+ if (env.CYBER_MUX_PANE) return {
67
+ mux: env.CYBER_MUX === "herdr" ? "herdr" : env.CYBER_MUX === "wezterm" ? "wezterm" : "tmux",
68
+ pane: env.CYBER_MUX_PANE
69
+ };
70
+ const tmux = PANE_ENV.tmux(env);
71
+ if (tmux) return {
72
+ mux: "tmux",
73
+ pane: tmux
74
+ };
75
+ const herdr = PANE_ENV.herdr(env);
76
+ if (herdr) return {
77
+ mux: "herdr",
78
+ pane: herdr
79
+ };
80
+ const wezterm = PANE_ENV.wezterm(env);
81
+ if (wezterm) return {
82
+ mux: "wezterm",
83
+ pane: wezterm
84
+ };
85
+ }
86
+ /**
87
+ * Two-mode multiplexer detection.
88
+ *
89
+ * Fast-path: `$CYBER_MUX` (tmux | herdr | screen | none) is trusted outright — this also serves as
90
+ * an OVERRIDE (`=none` forces no-mux even inside a real multiplexer). `$CYBER_MUX_PANE` carries the
91
+ * pane id alongside it.
92
+ *
93
+ * Discovery (else): walk the process ancestry from `$$` via `ps -o ppid=,comm= -p <pid>`, since the
94
+ * tool's own shell may not be the human's pane. `$TMUX`/`$HERDR_ENV` are NOT trusted alone — they
95
+ * are used only as a fast-positive hint the ancestry walk falls back to when the walk itself is
96
+ * inconclusive (e.g. `ps` unavailable), never as a substitute for it.
97
+ */
98
+ function probeMultiplexer(exec, env, opts = {}) {
99
+ if (isKnownMux(env.CYBER_MUX)) return {
100
+ mux: env.CYBER_MUX,
101
+ ...env.CYBER_MUX_PANE ? { pane: env.CYBER_MUX_PANE } : {},
102
+ via: "env"
103
+ };
104
+ if (opts.discover === false) return {
105
+ mux: "none",
106
+ via: "ancestry"
107
+ };
108
+ return discoverByAncestry(exec, env);
109
+ }
110
+ const MUX_COMM = [
111
+ {
112
+ re: /^tmux(:|$)/,
113
+ mux: "tmux"
114
+ },
115
+ {
116
+ re: /^herdr(:|$)/,
117
+ mux: "herdr"
118
+ },
119
+ {
120
+ re: /^wezterm(-gui|-mux-server)?(:|$)/,
121
+ mux: "wezterm"
122
+ },
123
+ {
124
+ re: /^screen(:|$)/,
125
+ mux: "screen"
126
+ }
127
+ ];
128
+ /** The per-pane env var for a mux, via the shared `PANE_ENV` table; undefined for screen/none. */
129
+ function paneFor(mux, env) {
130
+ return mux === "tmux" || mux === "herdr" || mux === "wezterm" ? PANE_ENV[mux](env) : void 0;
131
+ }
132
+ const MAX_ANCESTORS = 32;
133
+ function walkAncestry(exec, env) {
134
+ let pid = process.pid;
135
+ const seen = /* @__PURE__ */ new Set();
136
+ for (let i = 0; i < MAX_ANCESTORS; i++) {
137
+ if (seen.has(pid)) break;
138
+ seen.add(pid);
139
+ const line = exec("ps", [
140
+ "-o",
141
+ "ppid=,comm=",
142
+ "-p",
143
+ String(pid)
144
+ ]);
145
+ if (!line) break;
146
+ const trimmed = line.trim();
147
+ const spaceIdx = trimmed.indexOf(" ");
148
+ const ppidStr = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
149
+ const comm = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
150
+ const ppid = Number.parseInt(ppidStr, 10);
151
+ for (const entry of MUX_COMM) if (entry.re.test(comm)) return {
152
+ mux: entry.mux,
153
+ pane: paneFor(entry.mux, env),
154
+ via: "ancestry"
155
+ };
156
+ if (!Number.isFinite(ppid) || ppid <= 1) break;
157
+ pid = ppid;
158
+ }
159
+ }
160
+ function discoverByAncestry(exec, env) {
161
+ const found = walkAncestry(exec, env);
162
+ if (found) return found;
163
+ if (env.TMUX) return {
164
+ mux: "tmux",
165
+ pane: paneFor("tmux", env),
166
+ via: "ancestry"
167
+ };
168
+ if (env.HERDR_ENV) return {
169
+ mux: "herdr",
170
+ pane: paneFor("herdr", env),
171
+ via: "ancestry"
172
+ };
173
+ if (env.WEZTERM_PANE) return {
174
+ mux: "wezterm",
175
+ pane: paneFor("wezterm", env),
176
+ via: "ancestry"
177
+ };
178
+ return {
179
+ mux: "none",
180
+ via: "ancestry"
181
+ };
182
+ }
183
+ //#endregion
184
+ //#region src/env-fallback.ts
185
+ /**
186
+ * The env-prefix fallback — the one compensation for a route that could not set env at birth.
187
+ *
188
+ * env is native at every tier on both backends EXCEPT herdr's worktree `create`/`open`, which take
189
+ * no env parameter (0.7.4 answers `--env` with `unknown option`). A route that hit that wall carries
190
+ * env the only way left: as an `env KEY=VALUE` prefix on the command the pane runs. It is a LAST
191
+ * resort — the values land in `ps` output and the pane's shell history — and it only works when there
192
+ * IS a command to ride; with none, the honest outcome is to warn, never to drop silently.
193
+ *
194
+ * This lives in one module, called by both routes that can lose env (the CLI worktree verbs and the
195
+ * template walk's root pane), so the rule cannot be wired on one and forgotten on the other. Only a
196
+ * route that lost env may call it: prefixing over a natively-set env would push the values into `ps`
197
+ * and shell history on every route, the exact cost the prefix exists to pay only when it must.
198
+ */
199
+ /**
200
+ * Single-quote a value for a shell command line. Everything is literal inside single quotes, so the
201
+ * only escape needed is for a single quote itself: end the quoting, emit an escaped `'`, reopen.
202
+ * Without this a value carrying a space or a quote would split into extra words, or unbalance the
203
+ * line outright.
204
+ */
205
+ function shellQuote(value) {
206
+ return `'${value.replace(/'/g, `'\\''`)}'`;
207
+ }
208
+ /** `env K=V …` with a trailing space, ready to prepend to a command line. Values are shell-quoted. */
209
+ function envPrefix(env) {
210
+ return `env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} `;
211
+ }
212
+ /**
213
+ * Given the env a route could not carry and the command (if any) that would run in the opened pane,
214
+ * decide how env rides in. With a command, env is prefixed onto it and the pane carries the value;
215
+ * with none, env is dropped and the caller warns. No env (or an empty map) is `carried` unchanged, so
216
+ * a caller on the losing route can call this unconditionally and get the right command back.
217
+ */
218
+ function envFallback(env, command) {
219
+ if (env === void 0 || Object.keys(env).length === 0) return {
220
+ kind: "carried",
221
+ command
222
+ };
223
+ if (command === void 0) return {
224
+ kind: "dropped",
225
+ variables: Object.keys(env)
226
+ };
227
+ return {
228
+ kind: "carried",
229
+ command: `${envPrefix(env)}${command}`
230
+ };
231
+ }
232
+ //#endregion
233
+ //#region src/worktree.ts
234
+ /**
235
+ * This module's own refusals and failures — plain cyber-mux prose, never a dependency's raw words.
236
+ * `reportWorktreeFailure` (`cli.ts`) forwards a `WorktreeGitError`'s message onto stdout verbatim
237
+ * because it is safe to: everything thrown here is this CLI's own text. Anything else that reaches
238
+ * that catch-all (a `session.tmux.ts`/`session.herdr.ts` throw, which embeds the backend's own name
239
+ * and its raw stderr via `withReason`) is a different case and is translated, not forwarded.
240
+ */
241
+ var WorktreeGitError = class extends Error {};
242
+ /** The only worktree backend at MVP — plain `git worktree`. */
243
+ const gitWorktreeAdapter = {
244
+ add(exec, opts) {
245
+ const args = [
246
+ "-C",
247
+ opts.primaryRoot,
248
+ "worktree",
249
+ "add",
250
+ "-b",
251
+ opts.branch,
252
+ opts.path
253
+ ];
254
+ if (opts.base) args.push(opts.base);
255
+ if (exec("git", args) === null) throw new WorktreeGitError(`git worktree add failed for ${opts.path}`);
256
+ return {
257
+ root: resolve(opts.path),
258
+ branch: opts.branch
259
+ };
260
+ },
261
+ remove(exec, path, opts) {
262
+ if (exec("git", [
263
+ "-C",
264
+ opts.primaryRoot,
265
+ "worktree",
266
+ "remove",
267
+ path,
268
+ "--force"
269
+ ]) === null) throw new WorktreeGitError(`git worktree remove failed for ${path}`);
270
+ }
271
+ };
272
+ /**
273
+ * Resolve the primary checkout's root regardless of whether the caller's cwd is the primary
274
+ * checkout or a linked worktree — `--git-common-dir` always points at the main repo's `.git`.
275
+ */
276
+ function resolvePrimaryRoot(exec) {
277
+ const commonDir = exec("git", [
278
+ "rev-parse",
279
+ "--path-format=absolute",
280
+ "--git-common-dir"
281
+ ]);
282
+ if (!commonDir) throw new WorktreeGitError("cannot resolve the primary checkout — not inside a git repository");
283
+ return dirname(commonDir);
284
+ }
285
+ /**
286
+ * The single normalization point for every path that gets MATCHED against another — a multiplexer
287
+ * reports its own checkout paths, and those only line up with git's if both sides are resolved the
288
+ * same way (a symlinked repo, or macOS's `/tmp` → `/private/tmp`, otherwise silently fails to
289
+ * match). Falls back to `resolve` for a path that isn't on disk, where there is no link to follow.
290
+ */
291
+ function normalizeWorktreePath(path) {
292
+ try {
293
+ return realpathSync.native(path);
294
+ } catch {
295
+ return resolve(path);
296
+ }
297
+ }
298
+ /**
299
+ * Every worktree of the repo, straight from git. These are the facts — path, branch, linked,
300
+ * prunable — on EVERY backend: a multiplexer that also happens to enumerate worktrees is only
301
+ * re-reading git, so reading them here is what keeps two backends from ever disagreeing about the
302
+ * same worktree. The one fact git cannot answer — which workspace a worktree is open in — is joined
303
+ * in by the caller.
304
+ */
305
+ function listWorktreesFromGit(exec, primaryRoot) {
306
+ const out = exec("git", [
307
+ "-C",
308
+ primaryRoot,
309
+ "worktree",
310
+ "list",
311
+ "--porcelain"
312
+ ]);
313
+ if (!out) return [];
314
+ const normalizedPrimary = normalizeWorktreePath(primaryRoot);
315
+ return out.split("\n\n").map((record) => record.trim()).filter((record) => record.startsWith("worktree ")).map((record) => {
316
+ const lines = record.split("\n");
317
+ const root = normalizeWorktreePath(lines[0].slice(9));
318
+ return {
319
+ root,
320
+ branch: lines.find((line) => line.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, ""),
321
+ linked: root !== normalizedPrimary,
322
+ prunable: lines.some((line) => line === "prunable" || line.startsWith("prunable "))
323
+ };
324
+ });
325
+ }
326
+ /**
327
+ * Refuse the primary checkout: a spawned session's resolved worktree root must never be the primary
328
+ * checkout itself.
329
+ */
330
+ function assertDistinctFromPrimary(worktreeRoot, primaryRoot) {
331
+ if (resolve(worktreeRoot) === resolve(primaryRoot)) throw new WorktreeGitError("refusing to run in the primary checkout — spawn a worktree distinct from the primary checkout");
332
+ }
333
+ /**
334
+ * Default worktree location — a sibling of the primary checkout (`<parent>/<repo>.worktrees/<name>`),
335
+ * never nested inside the primary's own working tree (an untracked-but-present nested worktree
336
+ * pollutes `git status` in the primary and confuses tools that walk the tree expecting only the
337
+ * primary's own files).
338
+ */
339
+ function resolveWorktreePath(primaryRoot, name) {
340
+ return join(dirname(primaryRoot), `${basename(primaryRoot)}.worktrees`, name);
341
+ }
342
+ /** Whether a worktree has uncommitted changes — gates a safe remove unless the caller forces it. */
343
+ function isDirty(exec, worktreeRoot) {
344
+ return !!exec("git", [
345
+ "-C",
346
+ worktreeRoot,
347
+ "status",
348
+ "--porcelain"
349
+ ]);
350
+ }
351
+ /**
352
+ * Remove a worktree the safe way: refuse the primary checkout (absolute — `force` never overrides
353
+ * it), tolerate a worktree already gone from disk, and refuse to discard uncommitted changes unless
354
+ * `force` is set.
355
+ *
356
+ * `releaseBinding` detaches whatever a multiplexer has bound to this checkout (a herdr workspace).
357
+ * It stays an opaque callback so this module owes nothing to the session seam. Its ORDER is a
358
+ * specified property, not an incidental one:
359
+ *
360
+ * - every gate runs BEFORE it, so a REFUSED removal has no side effect — a dirty worktree that
361
+ * fails the check must not lose its workspace on the way out;
362
+ * - it runs BEFORE git removes the checkout, so no workspace is ever left pointing at a deleted
363
+ * directory (and a held cwd can block the removal outright).
364
+ */
365
+ function removeWorktreeSafely(exec, path, opts) {
366
+ assertDistinctFromPrimary(path, opts.primaryRoot);
367
+ if (!existsSync(path)) {
368
+ opts.releaseBinding?.();
369
+ return;
370
+ }
371
+ if (!opts.force && isDirty(exec, path)) throw new WorktreeGitError(`worktree "${path}" has uncommitted changes — pass --force to discard them`);
372
+ opts.releaseBinding?.();
373
+ gitWorktreeAdapter.remove(exec, path, { primaryRoot: opts.primaryRoot });
374
+ }
375
+ //#endregion
376
+ //#region src/session.herdr.ts
377
+ /**
378
+ * herdr backend — detected via `$HERDR_ENV`. herdr (https://herdr.dev) is an agent-aware terminal
379
+ * multiplexer that also reports real busy-state (working / idle / blocked / done); this adapter
380
+ * only drives its pane lifecycle, not the state feed. Talks to herdr's own CLI (`herdr pane ...`)
381
+ * rather than its Unix-socket API, so it composes with this codebase's synchronous `Exec`
382
+ * convention exactly like the tmux adapter — no new client/transport needed.
383
+ *
384
+ * The pane lifecycle (split/run/read/close) is verified against a live herdr binary; `pane split`
385
+ * returns a JSON `pane_info` envelope whose id is extracted in `parsePaneId`.
386
+ */
387
+ const herdrSessionAdapter = {
388
+ name: "herdr",
389
+ canSizeSplits: true,
390
+ open(exec, opts) {
391
+ const at = opts.at ?? "tab";
392
+ const label = opts.label ? ["--label", opts.label] : [];
393
+ const env = envFlags(opts.env);
394
+ let opened;
395
+ if (at === "workspace") {
396
+ const out = exec("herdr", [
397
+ "workspace",
398
+ "create",
399
+ "--cwd",
400
+ opts.cwd,
401
+ ...label,
402
+ ...env,
403
+ "--no-focus"
404
+ ]);
405
+ if (!out) throw new Error(withReason(exec, "herdr workspace create failed"));
406
+ opened = parseRootPaneId(out, "herdr workspace create");
407
+ } else if (at === "tab") {
408
+ const out = exec("herdr", [
409
+ "tab",
410
+ "create",
411
+ "--cwd",
412
+ opts.cwd,
413
+ ...label,
414
+ ...env,
415
+ "--no-focus"
416
+ ]);
417
+ if (!out) throw new Error(withReason(exec, "herdr tab create failed"));
418
+ opened = parseRootPaneId(out, "herdr tab create");
419
+ } else {
420
+ const direction = at === "pane:down" ? "down" : "right";
421
+ const from = opts.from ? [opts.from.id] : ["--current"];
422
+ const size = opts.ratio != null ? ["--ratio", String(opts.ratio)] : [];
423
+ const out = exec("herdr", [
424
+ "pane",
425
+ "split",
426
+ ...from,
427
+ "--direction",
428
+ direction,
429
+ "--cwd",
430
+ opts.cwd,
431
+ ...size,
432
+ ...env
433
+ ]);
434
+ if (!out) throw new Error(withReason(exec, "herdr pane split failed"));
435
+ opened = parsePaneId(out);
436
+ if (opts.label) herdrSessionAdapter.rename(exec, opened, "pane", opts.label);
437
+ }
438
+ if (opts.launch) herdrSessionAdapter.submit(exec, opened, opts.launch);
439
+ return opened;
440
+ },
441
+ rename(exec, target, tier, name) {
442
+ exec("herdr", [
443
+ tier,
444
+ "rename",
445
+ target.id,
446
+ name
447
+ ]);
448
+ },
449
+ group() {},
450
+ worktree: herdrWorktreeCapability(),
451
+ sendText(exec, target, text) {
452
+ exec("herdr", [
453
+ "pane",
454
+ "send-text",
455
+ target.id,
456
+ text
457
+ ]);
458
+ },
459
+ sendKeys(exec, target, keys) {
460
+ exec("herdr", [
461
+ "pane",
462
+ "send-keys",
463
+ target.id,
464
+ ...keys
465
+ ]);
466
+ },
467
+ submit(exec, target, text) {
468
+ if (!text) {
469
+ exec("herdr", [
470
+ "pane",
471
+ "send-keys",
472
+ target.id,
473
+ "Enter"
474
+ ]);
475
+ return;
476
+ }
477
+ exec("herdr", [
478
+ "pane",
479
+ "run",
480
+ target.id,
481
+ text
482
+ ]);
483
+ },
484
+ read(exec, target, opts) {
485
+ const args = [
486
+ "pane",
487
+ "read",
488
+ target.id,
489
+ "--source",
490
+ "visible"
491
+ ];
492
+ if (opts?.lines != null) args.push("--lines", String(opts.lines));
493
+ return exec("herdr", args) ?? "";
494
+ },
495
+ focus(exec, target) {
496
+ const { workspaceId, tabId } = parsePaneLocation$1(exec("herdr", [
497
+ "pane",
498
+ "get",
499
+ target.id
500
+ ]), target.id);
501
+ exec("herdr", [
502
+ "workspace",
503
+ "focus",
504
+ workspaceId
505
+ ]);
506
+ exec("herdr", [
507
+ "tab",
508
+ "focus",
509
+ tabId
510
+ ]);
511
+ },
512
+ teardown(exec, target) {
513
+ exec("herdr", [
514
+ "pane",
515
+ "close",
516
+ target.id
517
+ ]);
518
+ },
519
+ paneExists(exec, target) {
520
+ return exec("herdr", [
521
+ "pane",
522
+ "read",
523
+ target.id,
524
+ "--source",
525
+ "visible"
526
+ ]) !== null;
527
+ },
528
+ isPaneFocused(exec, target) {
529
+ const out = exec("herdr", [
530
+ "pane",
531
+ "get",
532
+ target.id
533
+ ]);
534
+ if (out == null) return void 0;
535
+ try {
536
+ const focused = JSON.parse(out)?.result?.pane?.focused;
537
+ return typeof focused === "boolean" ? focused : void 0;
538
+ } catch {
539
+ return;
540
+ }
541
+ },
542
+ listPanes(exec) {
543
+ const out = exec("herdr", ["pane", "list"]);
544
+ if (!out) return [];
545
+ let panes;
546
+ try {
547
+ panes = JSON.parse(out)?.result?.panes;
548
+ } catch {
549
+ return [];
550
+ }
551
+ if (!Array.isArray(panes)) return [];
552
+ return panes.filter((p) => typeof p?.pane_id === "string").map((p) => ({
553
+ id: p.pane_id,
554
+ mux: "herdr",
555
+ harness: p.agent || void 0,
556
+ cwd: p.cwd,
557
+ label: p.label || void 0
558
+ }));
559
+ },
560
+ describeRegion(exec, target) {
561
+ return herdrRegionPanes(exec, target.id, herdrPaneDetails(exec));
562
+ },
563
+ /**
564
+ * herdr HAS a workspace tier, so the workspace is a fact the backend holds rather than one
565
+ * cyber-mux has to reconstruct: the caller's pane names its `workspace_id`, `tab list --workspace`
566
+ * enumerates that workspace's tabs, and `pane list --workspace` hands back every pane already
567
+ * stamped with the tab it sits in. No grouping tag is read here and none is written — the tier IS
568
+ * the group, which is exactly why `open` ignores `workspaceGroup` on this backend.
569
+ *
570
+ * The one indirection: geometry is per-PANE (`pane layout --pane`), never per-tab, so each tab's
571
+ * rects are fetched through any one pane that sits in it. That is safe and race-free, and both
572
+ * halves were established against 0.7.4: `pane layout` reports live geometry for an UNFOCUSED tab
573
+ * in a DIFFERENT workspace, so nothing has to be focused first and nothing moves while this runs.
574
+ *
575
+ * herdr's own native per-tab layout export would be the obvious road — it takes a `tab_id` — but
576
+ * `layout` is NOT a CLI verb in 0.7.4; it is socket-API-only, and this adapter speaks the CLI by
577
+ * design (so it composes with the synchronous `Exec` seam). The road is closed, hence the pane
578
+ * indirection.
579
+ */
580
+ describeWorkspace(exec, target) {
581
+ const { workspaceId } = parsePaneRecord(exec("herdr", [
582
+ "pane",
583
+ "get",
584
+ target.id
585
+ ]));
586
+ if (!workspaceId) throw new Error(withReason(exec, `herdr could not resolve the workspace around pane ${target.id}`));
587
+ const out = exec("herdr", [
588
+ "tab",
589
+ "list",
590
+ "--workspace",
591
+ workspaceId
592
+ ]);
593
+ if (!out) throw new Error(withReason(exec, `herdr could not enumerate the tabs of workspace ${workspaceId}`));
594
+ let reported;
595
+ try {
596
+ reported = JSON.parse(out)?.result?.tabs;
597
+ } catch {
598
+ throw new Error(`herdr tab list returned unparseable output: ${out.slice(0, 200)}`);
599
+ }
600
+ if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr reported no tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
601
+ const details = herdrPaneDetails(exec, workspaceId);
602
+ const tabs = [];
603
+ for (const reportedTab of reported) {
604
+ if (typeof reportedTab?.tab_id !== "string") continue;
605
+ const tabId = reportedTab.tab_id;
606
+ const anchor = [...details].find(([, detail]) => detail.tab === tabId)?.[0];
607
+ if (!anchor) throw new Error(`herdr reported no panes in tab ${tabId} of workspace ${workspaceId}`);
608
+ const tab = {
609
+ id: tabId,
610
+ panes: herdrRegionPanes(exec, anchor, details)
611
+ };
612
+ if (typeof reportedTab.label === "string" && reportedTab.label !== "") tab.label = reportedTab.label;
613
+ tabs.push(tab);
614
+ }
615
+ if (tabs.length === 0) throw new Error(`herdr reported no usable tabs in workspace ${workspaceId}: ${out.slice(0, 200)}`);
616
+ return tabs;
617
+ }
618
+ };
619
+ /**
620
+ * The rects of the region `paneId` sits in, joined with the cwd/label half.
621
+ *
622
+ * Two sources, because herdr splits the answer across two verbs: `pane layout` reports the region's
623
+ * rects (`layout.panes[].rect`) but carries no cwd and no label, while `pane list` carries both and
624
+ * no geometry. Neither alone can build a template — hence `details` is passed IN, so a caller reading
625
+ * many tabs pays for that list once rather than once per tab.
626
+ *
627
+ * `layout.splits[]` is deliberately ignored even though it reports `direction` and `ratio` outright.
628
+ * It is FLAT — `[{id:"split_0_root",...},{id:"split_1_0",...}]` — so the tree is recoverable only by
629
+ * parsing the parent out of that id string, a convention herdr's CLI help never documents and could
630
+ * respell without warning. The rects say the same thing in a fact herdr does promise, so the
631
+ * derivation runs off those; see `describeRegion` in `session.ts`.
632
+ */
633
+ function herdrRegionPanes(exec, paneId, details) {
634
+ const out = exec("herdr", [
635
+ "pane",
636
+ "layout",
637
+ "--pane",
638
+ paneId
639
+ ]);
640
+ if (!out) throw new Error(withReason(exec, `herdr could not describe the region around pane ${paneId}`));
641
+ let reported;
642
+ try {
643
+ reported = JSON.parse(out)?.result?.layout?.panes;
644
+ } catch {
645
+ throw new Error(`herdr pane layout returned unparseable output: ${out.slice(0, 200)}`);
646
+ }
647
+ if (!Array.isArray(reported) || reported.length === 0) throw new Error(`herdr pane layout reported no panes for ${paneId}: ${out.slice(0, 200)}`);
648
+ return reported.filter((p) => typeof p?.pane_id === "string").map((p) => {
649
+ const detail = details.get(p.pane_id);
650
+ const pane = {
651
+ id: p.pane_id,
652
+ rect: {
653
+ x: p.rect?.x ?? 0,
654
+ y: p.rect?.y ?? 0,
655
+ width: p.rect?.width ?? 0,
656
+ height: p.rect?.height ?? 0
657
+ }
658
+ };
659
+ if (detail?.cwd) pane.cwd = detail.cwd;
660
+ if (detail?.label) pane.label = detail.label;
661
+ return pane;
662
+ });
663
+ }
664
+ /**
665
+ * Each pane's cwd, label and tab, keyed by pane id — the half `pane layout` does not report.
666
+ *
667
+ * `workspace` scopes the list to one workspace when the caller has one to scope by; omitting it lists
668
+ * every pane herdr can see, which is what a single-region read wants (it keys by pane id and never
669
+ * cares which workspace a pane came from).
670
+ */
671
+ function herdrPaneDetails(exec, workspace) {
672
+ const details = /* @__PURE__ */ new Map();
673
+ const out = exec("herdr", [
674
+ "pane",
675
+ "list",
676
+ ...workspace ? ["--workspace", workspace] : []
677
+ ]);
678
+ if (!out) return details;
679
+ let panes;
680
+ try {
681
+ panes = JSON.parse(out)?.result?.panes;
682
+ } catch {
683
+ return details;
684
+ }
685
+ if (!Array.isArray(panes)) return details;
686
+ for (const pane of panes) {
687
+ if (typeof pane?.pane_id !== "string") continue;
688
+ details.set(pane.pane_id, {
689
+ cwd: pane.cwd,
690
+ label: pane.label,
691
+ tab: pane.tab_id
692
+ });
693
+ }
694
+ return details;
695
+ }
696
+ /**
697
+ * herdr's repeatable `--env KEY=VALUE` — spelled the same way by exactly three verbs: `pane split`,
698
+ * `workspace create` and `tab create`, each backed by a native `env` Record in the socket schema
699
+ * (protocol 16).
700
+ *
701
+ * `worktree create`/`worktree open` are deliberately NOT in that list: their params are
702
+ * `[base, branch, cwd, focus, label, path, workspace_id]` and
703
+ * `[branch, cwd, focus, label, path, workspace_id]` — no `env` — and 0.7.4 rejects the flag with
704
+ * `unknown option: --env`. A caller needing env on that route uses the command-prefix fallback.
705
+ */
706
+ function envFlags(env) {
707
+ return env ? Object.entries(env).flatMap(([k, v]) => ["--env", `${k}=${v}`]) : [];
708
+ }
709
+ /**
710
+ * Launch a command in a worktree's root pane, carrying env the worktree verb could not set at birth.
711
+ * The prefix-or-warn rule is the seam's (`env-fallback.ts`); this is the one route that invokes it,
712
+ * because it is the one route that loses env. With a command, env rides in as a prefix; with none and
713
+ * env asked for, it warns to stderr (stdout stays machine-readable) rather than dropping in silence.
714
+ */
715
+ function carryLaunch(exec, target, env, launch) {
716
+ const fallback = envFallback(env, launch);
717
+ if (fallback.kind === "dropped") {
718
+ 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
719
+ `);
720
+ return;
721
+ }
722
+ if (fallback.command !== void 0) herdrSessionAdapter.submit(exec, target, fallback.command);
723
+ }
724
+ /**
725
+ * `herdr pane split` emits a JSON envelope, not a bare id:
726
+ * `{"id":"cli:pane:split","result":{"pane":{"pane_id":"w3:pB", ...},"type":"pane_info"}}`.
727
+ * The pane id herdr's other `pane` subcommands accept lives at `.result.pane.pane_id`. Extract it —
728
+ * passing the whole blob downstream lands it in a filename and blows the path length limit.
729
+ */
730
+ function parsePaneId(out) {
731
+ return parseOpenedPane(out, "herdr pane split", "pane");
732
+ }
733
+ /**
734
+ * `herdr pane get <id>` emits `{"result":{"pane":{"workspace_id":...,"tab_id":...,...}}}`, or an
735
+ * error envelope when the id no longer names a live pane. Every unresolvable shape — `out` is null
736
+ * (an Exec failure), the JSON does not parse, or a field is missing/empty/not a string — folds to the
737
+ * field simply being absent, so each caller states its OWN failure rather than inheriting one
738
+ * phrased for somebody else's verb.
739
+ */
740
+ function parsePaneRecord(out) {
741
+ if (out == null) return {};
742
+ try {
743
+ const pane = JSON.parse(out)?.result?.pane;
744
+ return {
745
+ workspaceId: nonEmpty(pane?.workspace_id),
746
+ tabId: nonEmpty(pane?.tab_id)
747
+ };
748
+ } catch {
749
+ return {};
750
+ }
751
+ }
752
+ function nonEmpty(value) {
753
+ return typeof value === "string" && value !== "" ? value : void 0;
754
+ }
755
+ /**
756
+ * The pane's workspace and tab, or a throw — so `focus` never issues a workspace/tab switch against a
757
+ * pane it couldn't actually resolve.
758
+ */
759
+ function parsePaneLocation$1(out, id) {
760
+ const { workspaceId, tabId } = parsePaneRecord(out);
761
+ if (!workspaceId || !tabId) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
762
+ return {
763
+ workspaceId,
764
+ tabId
765
+ };
766
+ }
767
+ /**
768
+ * herdr binds a git worktree to a workspace as a first-class record, and that binding is what its UI
769
+ * groups a repo's checkouts by. Only `worktree create`/`worktree open` produce it: `git worktree add`
770
+ * followed by `workspace create --cwd <checkout>` yields a workspace herdr does not know is a
771
+ * worktree at all, left out of the group. Hence this capability — see `WorktreeWorkspaceCapability`
772
+ * for what it deliberately does not own.
773
+ *
774
+ * Every call pins the source repo with `--cwd <primaryRoot>` rather than relying on the caller's
775
+ * ambient process cwd (matching how the git adapter always passes `-C <primaryRoot>`), and opens
776
+ * with `--no-focus` so spawning never steals the caller's attention.
777
+ */
778
+ function herdrWorktreeCapability() {
779
+ return {
780
+ createInWorkspace(exec, opts) {
781
+ const args = [
782
+ "worktree",
783
+ "create",
784
+ "--cwd",
785
+ opts.primaryRoot,
786
+ "--branch",
787
+ opts.branch,
788
+ "--path",
789
+ opts.path
790
+ ];
791
+ if (opts.base) args.push("--base", opts.base);
792
+ if (opts.label) args.push("--label", opts.label);
793
+ args.push("--no-focus");
794
+ const out = exec("herdr", args);
795
+ if (!out) throw new Error(withReason(exec, "herdr worktree create failed"));
796
+ const created = parseWorktreeWorkspace(out, "herdr worktree create");
797
+ carryLaunch(exec, created.target, opts.env, opts.launch);
798
+ return created;
799
+ },
800
+ openInWorkspace(exec, opts) {
801
+ const args = [
802
+ "worktree",
803
+ "open",
804
+ "--cwd",
805
+ opts.primaryRoot,
806
+ "--path",
807
+ opts.path
808
+ ];
809
+ if (opts.label) args.push("--label", opts.label);
810
+ args.push("--no-focus");
811
+ const out = exec("herdr", args);
812
+ if (!out) throw new Error(withReason(exec, "herdr worktree open failed"));
813
+ const opened = parseWorktreeWorkspace(out, "herdr worktree open");
814
+ carryLaunch(exec, opened.target, opts.env, opts.launch);
815
+ return opened;
816
+ },
817
+ bindings(exec, opts) {
818
+ return parseWorktreeBindings(exec("herdr", [
819
+ "worktree",
820
+ "list",
821
+ "--cwd",
822
+ opts.primaryRoot
823
+ ]));
824
+ },
825
+ releaseWorkspace(exec, workspace) {
826
+ exec("herdr", [
827
+ "workspace",
828
+ "close",
829
+ workspace
830
+ ]);
831
+ }
832
+ };
833
+ }
834
+ /**
835
+ * `herdr workspace create` and `herdr tab create` both emit their new root pane at
836
+ * `.result.root_pane.pane_id` (a different path than `pane split`'s `.result.pane.pane_id`).
837
+ * `label` names the command in error messages (e.g. "herdr workspace create").
838
+ */
839
+ function parseRootPaneId(out, label) {
840
+ return parseOpenedPane(out, label, "root_pane");
841
+ }
842
+ /**
843
+ * Every pane herdr emits carries its own `workspace_id` alongside its `pane_id`, on EVERY route —
844
+ * `workspace create` (which reports the workspace it just made), `tab create` (the workspace the tab
845
+ * was created in), and `pane split` (the workspace the split landed in, i.e. the caller's). Verified
846
+ * against herdr 0.7.4. That is why the workspace costs no extra call: it rides in on the same output
847
+ * the pane id is already read from, so probing for it separately would buy nothing and cost a round
848
+ * trip per open.
849
+ *
850
+ * The pane id is required — a route that cannot name its pane has failed. The workspace is NOT: it
851
+ * is read opportunistically and left absent when missing rather than throwing, so a herdr build that
852
+ * stops emitting it degrades to "cannot say" instead of breaking `open` outright. Absent is a
853
+ * meaning this seam already has (`OpenedPane.workspace`); a hard failure here would be inventing a
854
+ * new one for a field no caller is required to use.
855
+ */
856
+ function parseOpenedPane(out, label, key) {
857
+ let pane;
858
+ try {
859
+ pane = JSON.parse(out)?.result?.[key];
860
+ } catch {
861
+ throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
862
+ }
863
+ const paneId = pane?.pane_id;
864
+ if (typeof paneId !== "string" || paneId === "") throw new Error(`${label} output had no result.${key}.pane_id: ${out.slice(0, 200)}`);
865
+ const tab = pane?.tab_id;
866
+ if (typeof tab !== "string" || tab === "") throw new Error(`${label} output had no result.${key}.tab_id: ${out.slice(0, 200)}`);
867
+ const workspace = pane?.workspace_id;
868
+ return typeof workspace === "string" && workspace !== "" ? {
869
+ id: paneId,
870
+ tab,
871
+ workspace
872
+ } : {
873
+ id: paneId,
874
+ tab
875
+ };
876
+ }
877
+ /**
878
+ * `herdr worktree create` and `herdr worktree open` emit the same envelope: the root pane at
879
+ * `.result.root_pane.pane_id` (as `workspace create` does), the checkout at
880
+ * `.result.worktree.{path,branch}`, and the bound workspace at `.result.workspace.workspace_id`.
881
+ * That workspace id IS the binding — the whole reason to route through these instead of plain git.
882
+ * `label` names the command in error messages (e.g. "herdr worktree create").
883
+ *
884
+ * The root pane is read through `parseOpenedPane`, NOT re-parsed here: `root_pane` is the same record
885
+ * `workspace create` emits, so it carries the same `tab_id`, and one spelling is what keeps the two
886
+ * routes from disagreeing about a field both report. That tab is the region's root tab — what lets a
887
+ * caller handed this workspace group or rename it without reaching for the pane id, which would be
888
+ * green on tmux and silently broken on herdr.
889
+ */
890
+ function parseWorktreeWorkspace(out, label) {
891
+ let parsed;
892
+ try {
893
+ parsed = JSON.parse(out);
894
+ } catch {
895
+ throw new Error(`${label} returned unparseable output: ${out.slice(0, 200)}`);
896
+ }
897
+ const result = parsed?.result;
898
+ const target = parseOpenedPane(out, label, "root_pane");
899
+ const workspace = result?.workspace?.workspace_id;
900
+ const path = result?.worktree?.path;
901
+ const branch = result?.worktree?.branch;
902
+ if (typeof path !== "string" || path === "" || typeof branch !== "string" || branch === "") throw new Error(`${label} output had no result.worktree.{path,branch}: ${out.slice(0, 200)}`);
903
+ if (typeof workspace !== "string" || workspace === "") throw new Error(`${label} output had no result.workspace.workspace_id: ${out.slice(0, 200)}`);
904
+ return {
905
+ target,
906
+ worktree: {
907
+ root: resolve(path),
908
+ branch
909
+ },
910
+ workspace
911
+ };
912
+ }
913
+ /**
914
+ * `herdr worktree list` reports every worktree of the repo, each carrying `open_workspace_id` ONLY
915
+ * while a workspace is currently open on it. Everything else it reports (branch, linked, prunable)
916
+ * is herdr re-reading git — deliberately ignored here; git answers those for every backend.
917
+ * Defensive like `listPanes`: a query that cannot be read reports nothing rather than throwing.
918
+ */
919
+ function parseWorktreeBindings(out) {
920
+ const bindings = /* @__PURE__ */ new Map();
921
+ if (!out) return bindings;
922
+ let parsed;
923
+ try {
924
+ parsed = JSON.parse(out);
925
+ } catch {
926
+ return bindings;
927
+ }
928
+ const worktrees = parsed?.result?.worktrees ?? [];
929
+ if (!Array.isArray(worktrees)) return bindings;
930
+ for (const entry of worktrees) {
931
+ const path = entry?.path;
932
+ const workspace = entry?.open_workspace_id;
933
+ if (typeof path === "string" && path !== "" && typeof workspace === "string" && workspace !== "") bindings.set(normalizeWorktreePath(path), workspace);
934
+ }
935
+ return bindings;
936
+ }
937
+ //#endregion
938
+ //#region src/session.tmux.ts
939
+ /**
940
+ * The tmux window user option `SessionOpenOptions.workspaceGroup` is stored in. A user option (the
941
+ * `@` prefix) is tmux's own mechanism for a value it stores but never interprets, so tmux carries
942
+ * the tag without cyber-mux teaching it anything: it survives a window rename, and `list-windows`
943
+ * both reads it back (`#{@cm_ws}`) and filters on it server-side (`-f '#{==:#{@cm_ws},<id>}'`).
944
+ *
945
+ * Named here rather than spelled at each use so the write side and every read side cannot drift.
946
+ * Server-lifetime, like every window: it dies with the tmux server, along with the windows it tags.
947
+ */
948
+ const TMUX_WORKSPACE_GROUP_OPTION = "@cm_ws";
949
+ /**
950
+ * The tmux window user option a grouped window's OWN name is stored in — the name the caller gave the
951
+ * tab, beside the group id, because tmux's single `window_name` field no longer holds it.
952
+ *
953
+ * tmux has ONE name field per space. A caller that composes a display name out of a tab's name
954
+ * (`pool - editor`) has destroyed `editor`, and there is no sound way back: splitting on the separator
955
+ * is ambiguous (`acme - beta - main` reads two legal ways), and reading the display name verbatim
956
+ * re-prefixes it on every round trip (`pool - pool - editor`). So the original is stored here and read
957
+ * back from here — the same rule the group id follows, one tier down. The display name is a human's to
958
+ * read; this is what a machine reads.
959
+ *
960
+ * A user option (the `@` prefix) for `TMUX_WORKSPACE_GROUP_OPTION`'s reasons exactly: tmux stores it
961
+ * without interpreting it, it survives a window rename, and `list-windows` reads it back
962
+ * (`#{@cm_tab}`). Named here rather than spelled at each use so the write side and every read side
963
+ * cannot drift.
964
+ */
965
+ const TMUX_TAB_NAME_OPTION = "@cm_tab";
966
+ /** tmux backend — detected via `$TMUX`. */
967
+ const tmuxSessionAdapter = {
968
+ name: "tmux",
969
+ canSizeSplits: true,
970
+ open(exec, opts) {
971
+ const at = opts.at ?? "tab";
972
+ const window = at === "workspace" || at === "tab";
973
+ const env = opts.env ? Object.entries(opts.env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) : [];
974
+ const group = window && opts.workspaceGroup != null;
975
+ const format = "#{pane_id} #{window_id}";
976
+ let args;
977
+ if (window) args = [
978
+ "new-window",
979
+ "-d",
980
+ ...env,
981
+ "-c",
982
+ opts.cwd,
983
+ "-P",
984
+ "-F",
985
+ format
986
+ ];
987
+ else {
988
+ const from = opts.from ? ["-t", opts.from.id] : [];
989
+ const size = opts.ratio != null ? ["-l", toTmuxSize(opts.ratio)] : [];
990
+ args = [
991
+ "split-window",
992
+ at === "pane:down" ? "-v" : "-h",
993
+ ...from,
994
+ ...size,
995
+ ...env,
996
+ "-c",
997
+ opts.cwd,
998
+ "-P",
999
+ "-F",
1000
+ format
1001
+ ];
1002
+ }
1003
+ if (window && opts.label) args.splice(1, 0, "-n", opts.label);
1004
+ const out = exec("tmux", args);
1005
+ if (!out) throw new Error(withReason(exec, `tmux ${args[0]} failed`));
1006
+ const [pane, windowId] = splitOpenReport(out, args[0]);
1007
+ const target = {
1008
+ id: pane,
1009
+ tab: windowId
1010
+ };
1011
+ if (group && windowId) tmuxSessionAdapter.group(exec, { id: windowId }, opts.workspaceGroup);
1012
+ if (!window && opts.label) tmuxSessionAdapter.rename(exec, target, "pane", opts.label);
1013
+ if (opts.launch) tmuxSessionAdapter.submit(exec, target, opts.launch);
1014
+ return target;
1015
+ },
1016
+ rename(exec, target, tier, name) {
1017
+ if (tier === "tab") {
1018
+ exec("tmux", [
1019
+ "rename-window",
1020
+ "-t",
1021
+ target.id,
1022
+ name
1023
+ ]);
1024
+ return;
1025
+ }
1026
+ exec("tmux", [
1027
+ "select-pane",
1028
+ "-t",
1029
+ target.id,
1030
+ "-T",
1031
+ name
1032
+ ]);
1033
+ },
1034
+ group(exec, target, group, name) {
1035
+ exec("tmux", [
1036
+ "set-option",
1037
+ "-w",
1038
+ "-t",
1039
+ target.id,
1040
+ TMUX_WORKSPACE_GROUP_OPTION,
1041
+ group
1042
+ ]);
1043
+ if (name !== void 0) exec("tmux", [
1044
+ "set-option",
1045
+ "-w",
1046
+ "-t",
1047
+ target.id,
1048
+ TMUX_TAB_NAME_OPTION,
1049
+ name
1050
+ ]);
1051
+ },
1052
+ sendText(exec, target, text) {
1053
+ exec("tmux", [
1054
+ "send-keys",
1055
+ "-t",
1056
+ target.id,
1057
+ "-l",
1058
+ text
1059
+ ]);
1060
+ },
1061
+ sendKeys(exec, target, keys) {
1062
+ exec("tmux", [
1063
+ "send-keys",
1064
+ "-t",
1065
+ target.id,
1066
+ ...keys.map(toTmuxKey)
1067
+ ]);
1068
+ },
1069
+ submit(exec, target, text) {
1070
+ if (!text) {
1071
+ exec("tmux", [
1072
+ "send-keys",
1073
+ "-t",
1074
+ target.id,
1075
+ "Enter"
1076
+ ]);
1077
+ return;
1078
+ }
1079
+ tmuxSessionAdapter.sendText(exec, target, text);
1080
+ exec("tmux", [
1081
+ "send-keys",
1082
+ "-t",
1083
+ target.id,
1084
+ "Enter"
1085
+ ]);
1086
+ },
1087
+ read(exec, target, opts) {
1088
+ const args = [
1089
+ "capture-pane",
1090
+ "-p",
1091
+ "-t",
1092
+ target.id
1093
+ ];
1094
+ if (opts?.lines != null) args.push("-S", `-${opts.lines}`);
1095
+ return exec("tmux", args) ?? "";
1096
+ },
1097
+ focus(exec, target) {
1098
+ const { sessionName, windowId } = parsePaneLocation(exec("tmux", [
1099
+ "list-panes",
1100
+ "-a",
1101
+ "-F",
1102
+ "#{pane_id} #{session_name} #{window_id}"
1103
+ ]), target.id);
1104
+ exec("tmux", [
1105
+ "switch-client",
1106
+ "-t",
1107
+ sessionName
1108
+ ]);
1109
+ exec("tmux", [
1110
+ "select-window",
1111
+ "-t",
1112
+ windowId
1113
+ ]);
1114
+ exec("tmux", [
1115
+ "select-pane",
1116
+ "-t",
1117
+ target.id
1118
+ ]);
1119
+ },
1120
+ teardown(exec, target) {
1121
+ exec("tmux", [
1122
+ "kill-pane",
1123
+ "-t",
1124
+ target.id
1125
+ ]);
1126
+ },
1127
+ paneExists(exec, target) {
1128
+ if (exec("tmux", [
1129
+ "has-session",
1130
+ "-t",
1131
+ target.id
1132
+ ]) !== null) return true;
1133
+ return (exec("tmux", [
1134
+ "list-panes",
1135
+ "-a",
1136
+ "-F",
1137
+ "#{pane_id}"
1138
+ ]) ?? "").split("\n").includes(target.id);
1139
+ },
1140
+ isPaneFocused(exec, target) {
1141
+ const out = exec("tmux", [
1142
+ "list-panes",
1143
+ "-a",
1144
+ "-F",
1145
+ "#{pane_id} #{pane_active} #{window_active} #{session_attached}"
1146
+ ]);
1147
+ if (!out) return void 0;
1148
+ const line = out.split("\n").find((l) => l.split(" ")[0] === target.id);
1149
+ if (!line) return void 0;
1150
+ const [, paneActive, windowActive, sessionAttached] = line.split(" ");
1151
+ return paneActive === "1" && windowActive === "1" && sessionAttached !== "0" && sessionAttached !== void 0;
1152
+ },
1153
+ /**
1154
+ * Tab-separated, not space — the same rule `describeTmuxRegion` follows, and for the same reason:
1155
+ * `pane_current_path` and `pane_title` can both contain spaces. The old space-separated format
1156
+ * recovered the cwd by rejoining everything after the command, which works only while the cwd is
1157
+ * the LAST field. A label is a human's and may hold anything, so appending one to that format would
1158
+ * make both fields unrecoverable — `my worker` and `/repo/my dir` cannot be told apart by a space.
1159
+ * A tab can appear in neither id nor command, and the two free-text fields are separated by one.
1160
+ */
1161
+ listPanes(exec) {
1162
+ const out = exec("tmux", [
1163
+ "list-panes",
1164
+ "-a",
1165
+ "-F",
1166
+ "#{pane_id} #{pane_current_command} #{pane_current_path} #{pane_title} #{host}"
1167
+ ]);
1168
+ if (!out) return [];
1169
+ return out.split("\n").filter(Boolean).map((line) => {
1170
+ const [id, , cwd, title, host] = line.split(" ");
1171
+ const pane = {
1172
+ id: id ?? "",
1173
+ mux: "tmux"
1174
+ };
1175
+ if (cwd) pane.cwd = cwd;
1176
+ const label = paneLabel(title, host);
1177
+ if (label) pane.label = label;
1178
+ return pane;
1179
+ }).filter((p) => p.id !== "");
1180
+ },
1181
+ describeRegion(exec, target) {
1182
+ return describeTmuxRegion(exec, target.id);
1183
+ },
1184
+ /**
1185
+ * tmux has NO workspace tier — `workspace` and `tab` both collapse onto a Window — so a workspace
1186
+ * is not a fact this backend holds. What it holds is the grouping TAG the walk wrote
1187
+ * (`SessionOpenOptions.workspaceGroup`, stored in a window user option), so the read here is
1188
+ * literally *"which windows carry this group id"*.
1189
+ *
1190
+ * The tag, never the label. `list-windows -a` spans SESSIONS, so a bare name match would
1191
+ * over-collect a same-named window from another session, and taking the workspace off a
1192
+ * `<workspace> - <tab>` label is unsound in the first place (`acme - beta - main` splits two ways,
1193
+ * both legal). `-f '#{==:#{@cm_ws},<id>}'` keys on what actually identifies the group, filtered
1194
+ * server-side — the tag survives a window rename, which a name-encoded grouping does not.
1195
+ *
1196
+ * A window with NO tag is a workspace of ONE: the honest answer for a window nobody grouped, and
1197
+ * it costs no further call — the caller's own window is the whole workspace.
1198
+ */
1199
+ describeWorkspace(exec, target) {
1200
+ const out = exec("tmux", [
1201
+ "display-message",
1202
+ "-p",
1203
+ "-t",
1204
+ target.id,
1205
+ `#{window_id}\t#{${TMUX_WORKSPACE_GROUP_OPTION}}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`
1206
+ ]);
1207
+ if (!out) throw new Error(withReason(exec, `tmux could not resolve the workspace around pane ${target.id}`));
1208
+ const [windowId, group, ownName, ...nameParts] = out.split("\n")[0].split(" ");
1209
+ if (!windowId) throw new Error(`tmux did not report the window around pane ${target.id}`);
1210
+ if (!group) return [tmuxTab(exec, windowId, ownName, nameParts.join(" "))];
1211
+ const listed = exec("tmux", [
1212
+ "list-windows",
1213
+ "-a",
1214
+ "-F",
1215
+ `#{window_id}\t#{${TMUX_TAB_NAME_OPTION}}\t#{window_name}`,
1216
+ "-f",
1217
+ `#{==:#{${TMUX_WORKSPACE_GROUP_OPTION}},${group}}`
1218
+ ]);
1219
+ if (!listed) throw new Error(withReason(exec, `tmux could not enumerate the windows grouped as ${group}`));
1220
+ 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(" ")));
1221
+ if (tabs.length === 0) throw new Error(`tmux reported no windows grouped as ${group}`);
1222
+ return tabs;
1223
+ }
1224
+ };
1225
+ /**
1226
+ * One window, read as a tab: its id, the tab's OWN name, and its region's geometry.
1227
+ *
1228
+ * `ownName` is what `group` stored (`TMUX_TAB_NAME_OPTION`) and it WINS, because `windowName` is the
1229
+ * display name — on a grouped window that is the composed `pool - editor`, whose `editor` tmux's
1230
+ * single name field no longer holds. Reporting the display name instead would compound the prefix on
1231
+ * every capture/apply round trip (`pool - pool - editor`), and splitting it back apart is the unsound
1232
+ * parse the option exists to refuse.
1233
+ *
1234
+ * The window name is the FALLBACK, not a second guess: a window carrying no stored name is one nobody
1235
+ * composed a display name for, so its name already IS its own name. That covers the untagged window —
1236
+ * a workspace of one — and any window a caller grouped without naming.
1237
+ */
1238
+ function tmuxTab(exec, windowId, ownName, windowName) {
1239
+ const tab = {
1240
+ id: windowId,
1241
+ panes: describeTmuxRegion(exec, windowId)
1242
+ };
1243
+ const label = ownName || windowName;
1244
+ if (label) tab.label = label;
1245
+ return tab;
1246
+ }
1247
+ /**
1248
+ * Every pane of the region `id` names, with its rectangle. `id` is a pane id (that pane's own window)
1249
+ * or a window id (that window) — `list-panes -t` resolves both, which is what lets the region read and
1250
+ * the workspace read share one query instead of two that could drift apart.
1251
+ *
1252
+ * `-t` scopes `list-panes` to ONE window — the region tier, which is what capture captures. Without
1253
+ * `-a`, so this never reaches the panes of some other window.
1254
+ *
1255
+ * `#{pane_left}`/`#{pane_top}` are window-relative, and the widths exclude the divider column tmux
1256
+ * draws between panes (a 200-wide window split side by side reports 119 + 80, not 200) — both are
1257
+ * exactly what `RegionPane.rect` documents, so nothing is adjusted here.
1258
+ *
1259
+ * Tab-separated, not space: `pane_current_path` and `pane_title` can both contain spaces, and
1260
+ * splitting a path on spaces is how a directory with one in it silently becomes the wrong pane.
1261
+ */
1262
+ function describeTmuxRegion(exec, id) {
1263
+ const out = exec("tmux", [
1264
+ "list-panes",
1265
+ "-t",
1266
+ id,
1267
+ "-F",
1268
+ "#{pane_id} #{pane_left} #{pane_top} #{pane_width} #{pane_height} #{pane_current_path} #{pane_title} #{host}"
1269
+ ]);
1270
+ if (!out) throw new Error(withReason(exec, `tmux could not describe the region around pane ${id}`));
1271
+ const panes = [];
1272
+ for (const line of out.split("\n").filter(Boolean)) {
1273
+ const [paneId, left, top, width, height, cwd, title, host] = line.split(" ");
1274
+ if (!paneId) continue;
1275
+ const pane = {
1276
+ id: paneId,
1277
+ rect: {
1278
+ x: Number(left),
1279
+ y: Number(top),
1280
+ width: Number(width),
1281
+ height: Number(height)
1282
+ }
1283
+ };
1284
+ if (cwd) pane.cwd = cwd;
1285
+ const label = paneLabel(title, host);
1286
+ if (label) pane.label = label;
1287
+ panes.push(pane);
1288
+ }
1289
+ if (panes.length === 0) throw new Error(`tmux reported no panes in the region around pane ${id}`);
1290
+ return panes;
1291
+ }
1292
+ /**
1293
+ * A tmux pane's label — its title, unless that title is the hostname tmux handed it.
1294
+ *
1295
+ * **tmux has no "unset title"**: it defaults `pane_title` to the hostname, so a pane nobody ever named
1296
+ * reports a name nobody chose, and every pane in an untouched session reports the SAME one. Exporting
1297
+ * that would label them all `zeta`, and `zeta` would then resolve to every pane in the session —
1298
+ * ambiguity manufactured out of nothing. A title that differs from the host is one someone set
1299
+ * (cyber-mux's own `select-pane -T` among them), so it is the author's and survives.
1300
+ *
1301
+ * One home for the rule, called by BOTH reads — `listPanes` (which a name resolves against) and
1302
+ * `describeTmuxRegion` (which a capture exports). Two spellings of a heuristic this load-bearing is
1303
+ * how the listing and the capture come to disagree about which panes are named.
1304
+ *
1305
+ * The comparison is the workaround, not the shape of the thing: herdr has the honest primitive and
1306
+ * omits the key outright until a pane is renamed, so it needs no rule at all.
1307
+ */
1308
+ function paneLabel(title, host) {
1309
+ return title && title !== host ? title : void 0;
1310
+ }
1311
+ /**
1312
+ * The `-P -F '#{pane_id}\t#{window_id}'` report EVERY open asks for, split back into its two ids.
1313
+ * Tab-separated because neither id can contain a tab.
1314
+ *
1315
+ * A report that does not carry both throws rather than returning half an answer: the window is the
1316
+ * pane's tab, which `OpenedPane.tab` promises is always present, and it is also what a grouping open
1317
+ * tags. Guessing either would be worse than failing — a caller would name or group nothing and never
1318
+ * learn it.
1319
+ */
1320
+ function splitOpenReport(out, command) {
1321
+ const [pane, windowId] = out.split(" ");
1322
+ if (!pane || !windowId) throw new Error(`tmux ${command} did not report the new pane's id and window id`);
1323
+ return [pane, windowId];
1324
+ }
1325
+ /**
1326
+ * `ratio` is the fraction kept by the ORIGINAL pane; tmux's `-l` sizes the NEW one. So this INVERTS
1327
+ * — `1 - ratio` — where herdr's `--ratio` passes the same number through untouched. The two backends
1328
+ * genuinely convert in opposite directions, and applying the inversion to both (or to neither) is
1329
+ * the way this gets silently backwards: a 0.333 template would size the original pane at 67%.
1330
+ *
1331
+ * Percent rather than cells: tmux takes `-l` as either, and a percentage is the only form that means
1332
+ * the same thing without first querying the region's size.
1333
+ */
1334
+ function toTmuxSize(ratio) {
1335
+ return `${Math.round((1 - ratio) * 100)}%`;
1336
+ }
1337
+ /**
1338
+ * The core vocabulary's tmux spelling. Exactly one member differs — probed, not read off tmux(1):
1339
+ * tmux has no `Backspace` key name, so it would *type* the word (its unrecognized-token fallback);
1340
+ * its name for that key is `BSpace` (tmux(1): "the following special key names are accepted: Up,
1341
+ * Down, Left, Right, BSpace, BTab, DC ..."). Every other core key — `Up` `Down` `Left` `Right`
1342
+ * `Enter` `Escape` `Tab` `Space` `C-c` `F1`-`F12` — is already tmux's own name for it.
1343
+ *
1344
+ * Deliberately a rename table, NOT a validation table: a token outside the core is forwarded
1345
+ * verbatim (the contract), so this must not reject what it does not recognize. Keeping a full tmux
1346
+ * key list here would make the passthrough a second vocabulary to maintain.
1347
+ */
1348
+ const TMUX_KEY_RENAMES = { Backspace: "BSpace" };
1349
+ function toTmuxKey(key) {
1350
+ return TMUX_KEY_RENAMES[key] ?? key;
1351
+ }
1352
+ /**
1353
+ * `tmux list-panes -a -F '#{pane_id} #{session_name} #{window_id}'` lists every pane server-wide.
1354
+ * Resolving fails — no line's pane id matches `id` — when the pane no longer exists in the backend,
1355
+ * and that must throw so `focus` never issues a switch-client/select-window against a pane it
1356
+ * couldn't actually resolve.
1357
+ */
1358
+ function parsePaneLocation(out, id) {
1359
+ const line = (out ?? "").split("\n").find((l) => l.split(" ")[0] === id);
1360
+ if (!line) throw new Error(`peer's pane ${id} could not be resolved to beam to`);
1361
+ const [, sessionName, windowId] = line.split(" ");
1362
+ return {
1363
+ sessionName,
1364
+ windowId
1365
+ };
1366
+ }
1367
+ //#endregion
1368
+ //#region src/session.wezterm.ts
1369
+ /**
1370
+ * WezTerm backend — detected via `$WEZTERM_PANE`. Drives WezTerm's built-in multiplexer through
1371
+ * `wezterm cli …` (https://wezterm.org/cli/general.html), the same synchronous-CLI shape tmux and
1372
+ * herdr already give `Exec`.
1373
+ *
1374
+ * Probed from `wezterm cli --help`/the CLI reference docs only — there is no live WezTerm GUI in
1375
+ * this sandbox, so nothing here carries the "verified against a live binary" claim `session.tmux.ts`
1376
+ * and `session.herdr.ts` make. Several real capability gaps fell out of that probe, not just missing
1377
+ * polish:
1378
+ *
1379
+ * - **No `--env` on `spawn`/`split-pane` at all.** Unlike herdr (native everywhere except one
1380
+ * worktree route), WezTerm's CLI has no env flag on ANY space-creating command — every route is
1381
+ * the exception, so `open`'s env always rides the `envFallback` compensation (a `env K=V` prefix
1382
+ * on the launch command, or a stderr warning with none to ride), never a native flag.
1383
+ * - **No way to title a PANE**, at birth or after — `set-tab-title`/`set-window-title` exist, there
1384
+ * is no pane equivalent. `rename(..., 'pane', …)` throws; `open`'s pane-tier `label` degrades to a
1385
+ * stderr warning rather than silently dropping it or failing the whole open.
1386
+ * - **No focus-query primitive** — `list --format json`'s documented fields carry no active/focused
1387
+ * indicator for a pane, tab, or window. `isPaneFocused` always answers `undefined`, which is the
1388
+ * seam's own honest answer for "no primitive to report focus", not a workaround.
1389
+ * - **No per-key press primitive** — there is no `send-keys`-shaped verb, only `send-text`. The core
1390
+ * vocabulary is instead realized by encoding each key as its raw terminal byte sequence and typing
1391
+ * it via `send-text --no-paste`; see `WEZTERM_KEY_BYTES`.
1392
+ * - **No pane geometry** — `list --format json` reports a pane's `size` (rows/cols) but no position,
1393
+ * so there is nothing to build a `PaneRect` from. `describeRegion`/`describeWorkspace` are omitted
1394
+ * entirely, the same optional-omission `template save` already handles for a backend that cannot.
1395
+ * - **No git-worktree concept in the CLI at all** — no `worktree` subcommand, so like tmux this
1396
+ * backend never binds one to a workspace; `worktree` is omitted.
1397
+ *
1398
+ * `spawn`/`split-pane` report only the new pane's bare id — unlike tmux/herdr, which embed the tab
1399
+ * (and workspace) in the same `-F`/JSON envelope the pane id rides out on. So `OpenedPane.tab` costs
1400
+ * a follow-up `wezterm cli list --format json` lookup here, not a free read of output already held.
1401
+ */
1402
+ const weztermSessionAdapter = {
1403
+ name: "wezterm",
1404
+ canSizeSplits: true,
1405
+ open(exec, opts) {
1406
+ const at = opts.at ?? "tab";
1407
+ if (at === "workspace") {
1408
+ const workspace = opts.label ?? `cyber-mux-${randomUUID().slice(0, 8)}`;
1409
+ const out = exec("wezterm", [
1410
+ "cli",
1411
+ "spawn",
1412
+ "--new-window",
1413
+ "--workspace",
1414
+ workspace,
1415
+ "--cwd",
1416
+ opts.cwd
1417
+ ]);
1418
+ if (!out) throw new Error(withReason(exec, "wezterm cli spawn --new-window failed"));
1419
+ const pane = out.trim();
1420
+ if (!pane) throw new Error("wezterm cli spawn --new-window did not report the new pane id");
1421
+ const opened = {
1422
+ id: pane,
1423
+ tab: resolveTab(exec, pane),
1424
+ workspace
1425
+ };
1426
+ runLaunch(exec, opened, opts.env, opts.launch);
1427
+ return opened;
1428
+ }
1429
+ if (at === "tab") {
1430
+ const out = exec("wezterm", [
1431
+ "cli",
1432
+ "spawn",
1433
+ "--cwd",
1434
+ opts.cwd
1435
+ ]);
1436
+ if (!out) throw new Error(withReason(exec, "wezterm cli spawn failed"));
1437
+ const pane = out.trim();
1438
+ if (!pane) throw new Error("wezterm cli spawn did not report the new pane id");
1439
+ const opened = withTabAndWorkspace(exec, pane);
1440
+ if (opts.label) weztermSessionAdapter.rename(exec, { id: opened.tab }, "tab", opts.label);
1441
+ runLaunch(exec, opened, opts.env, opts.launch);
1442
+ return opened;
1443
+ }
1444
+ const direction = at === "pane:down" ? ["--bottom"] : ["--right"];
1445
+ const from = opts.from ? ["--pane-id", opts.from.id] : [];
1446
+ const size = opts.ratio != null ? ["--percent", toWeztermSize(opts.ratio)] : [];
1447
+ const out = exec("wezterm", [
1448
+ "cli",
1449
+ "split-pane",
1450
+ ...direction,
1451
+ ...from,
1452
+ ...size,
1453
+ "--cwd",
1454
+ opts.cwd
1455
+ ]);
1456
+ if (!out) throw new Error(withReason(exec, "wezterm cli split-pane failed"));
1457
+ const pane = out.trim();
1458
+ if (!pane) throw new Error("wezterm cli split-pane did not report the new pane id");
1459
+ const opened = withTabAndWorkspace(exec, pane);
1460
+ if (opts.label) process.stderr.write(`wezterm cannot name a pane — "${opts.label}" was not set on pane ${opened.id}\n`);
1461
+ runLaunch(exec, opened, opts.env, opts.launch);
1462
+ return opened;
1463
+ },
1464
+ rename(exec, target, tier, name) {
1465
+ if (tier === "tab") {
1466
+ exec("wezterm", [
1467
+ "cli",
1468
+ "set-tab-title",
1469
+ "--tab-id",
1470
+ target.id,
1471
+ name
1472
+ ]);
1473
+ return;
1474
+ }
1475
+ throw new Error(`wezterm cannot name a pane (only a tab or window) — asked to rename ${target.id}`);
1476
+ },
1477
+ group() {},
1478
+ sendText(exec, target, text) {
1479
+ exec("wezterm", [
1480
+ "cli",
1481
+ "send-text",
1482
+ "--pane-id",
1483
+ target.id,
1484
+ text
1485
+ ]);
1486
+ },
1487
+ sendKeys(exec, target, keys) {
1488
+ const bytes = keys.map((k) => WEZTERM_KEY_BYTES[k] ?? k).join("");
1489
+ exec("wezterm", [
1490
+ "cli",
1491
+ "send-text",
1492
+ "--pane-id",
1493
+ target.id,
1494
+ "--no-paste",
1495
+ bytes
1496
+ ]);
1497
+ },
1498
+ submit(exec, target, text) {
1499
+ if (!text) {
1500
+ weztermSessionAdapter.sendKeys(exec, target, ["Enter"]);
1501
+ return;
1502
+ }
1503
+ weztermSessionAdapter.sendText(exec, target, text);
1504
+ weztermSessionAdapter.sendKeys(exec, target, ["Enter"]);
1505
+ },
1506
+ read(exec, target, opts) {
1507
+ const args = [
1508
+ "cli",
1509
+ "get-text",
1510
+ "--pane-id",
1511
+ target.id
1512
+ ];
1513
+ if (opts?.lines != null) args.push("--start-line", String(-opts.lines));
1514
+ return exec("wezterm", args) ?? "";
1515
+ },
1516
+ focus(exec, target) {
1517
+ exec("wezterm", [
1518
+ "cli",
1519
+ "activate-pane",
1520
+ "--pane-id",
1521
+ target.id
1522
+ ]);
1523
+ },
1524
+ teardown(exec, target) {
1525
+ exec("wezterm", [
1526
+ "cli",
1527
+ "kill-pane",
1528
+ "--pane-id",
1529
+ target.id
1530
+ ]);
1531
+ },
1532
+ paneExists(exec, target) {
1533
+ return listWeztermPanes(exec).some((p) => String(p.pane_id) === target.id);
1534
+ },
1535
+ isPaneFocused() {},
1536
+ listPanes(exec) {
1537
+ return listWeztermPanes(exec).map((p) => {
1538
+ const pane = {
1539
+ id: String(p.pane_id),
1540
+ mux: "wezterm"
1541
+ };
1542
+ const cwd = weztermCwd(p.cwd);
1543
+ if (cwd) pane.cwd = cwd;
1544
+ return pane;
1545
+ });
1546
+ }
1547
+ };
1548
+ /**
1549
+ * `wezterm cli spawn`/`split-pane` report ONLY the new pane's bare id on stdout — unlike tmux/herdr,
1550
+ * neither embeds the tab (or workspace) in that same output. So the tab this pane landed in is a
1551
+ * follow-up `list --format json` lookup rather than a free read of an envelope already held.
1552
+ * `OpenedPane.tab` is still required — every multiplexer has the Tab level — it simply costs more
1553
+ * here than the "no extra call" property tmux/herdr get to claim.
1554
+ */
1555
+ function resolveTab(exec, pane) {
1556
+ const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
1557
+ if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
1558
+ return String(found.tab_id);
1559
+ }
1560
+ /** `resolveTab` plus the workspace the same lookup already answers — one call serves both facts. */
1561
+ function withTabAndWorkspace(exec, pane) {
1562
+ const found = listWeztermPanes(exec).find((p) => String(p.pane_id) === pane);
1563
+ if (!found) throw new Error(`wezterm did not report a tab for the new pane ${pane}`);
1564
+ return {
1565
+ id: pane,
1566
+ tab: String(found.tab_id),
1567
+ workspace: found.workspace
1568
+ };
1569
+ }
1570
+ /**
1571
+ * Env is native at NO tier on this backend — `spawn`/`split-pane` take no `--env` at all, unlike
1572
+ * herdr (native everywhere but one worktree route). So every `open` funnels through the same
1573
+ * fallback herdr's worktree route uses: with a launch command, env rides in as an `env K=V` prefix;
1574
+ * with none, a warning names what did not land. `envFallback` is a no-op when there is no env to
1575
+ * carry, so this is safe to call unconditionally.
1576
+ */
1577
+ function runLaunch(exec, target, env, launch) {
1578
+ const fallback = envFallback(env, launch);
1579
+ if (fallback.kind === "dropped") {
1580
+ 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
1581
+ `);
1582
+ return;
1583
+ }
1584
+ if (fallback.command !== void 0) weztermSessionAdapter.submit(exec, target, fallback.command);
1585
+ }
1586
+ /** One `wezterm cli list --format json` call, parsed defensively — never throws on bad output. */
1587
+ function listWeztermPanes(exec) {
1588
+ const out = exec("wezterm", [
1589
+ "cli",
1590
+ "list",
1591
+ "--format",
1592
+ "json"
1593
+ ]);
1594
+ if (!out) return [];
1595
+ let parsed;
1596
+ try {
1597
+ parsed = JSON.parse(out);
1598
+ } catch {
1599
+ return [];
1600
+ }
1601
+ if (!Array.isArray(parsed)) return [];
1602
+ return parsed.filter((p) => p != null && p.pane_id != null && p.tab_id != null && p.window_id != null);
1603
+ }
1604
+ /** `cwd` is reported as a `file://` URI; strip the scheme and host down to the bare path. */
1605
+ function weztermCwd(cwd) {
1606
+ if (!cwd) return void 0;
1607
+ const match = /^file:\/\/[^/]*(\/.*)$/.exec(cwd);
1608
+ return match ? decodeURIComponent(match[1]) : cwd;
1609
+ }
1610
+ /**
1611
+ * `ratio` is the fraction kept by the ORIGINAL pane; `--percent` sizes the NEW one (the issue's own
1612
+ * probe note, #47) — the same inversion tmux's `-l` needs, unlike herdr's pass-through.
1613
+ */
1614
+ function toWeztermSize(ratio) {
1615
+ return String(Math.round((1 - ratio) * 100));
1616
+ }
1617
+ /**
1618
+ * The core vocabulary, realized as raw terminal bytes rather than a backend key NAME — there is no
1619
+ * send-keys-shaped verb to name a key TO, only `send-text`. Escape sequences are the ANSI/VT100
1620
+ * "cursor key mode" forms every common shell/program already parses; `Backspace` sends DEL (`\x7f`),
1621
+ * what most terminals emit for that key today (probed, not read off any wezterm spec — wezterm ships
1622
+ * no such table because it has no key-name CLI surface to spec).
1623
+ *
1624
+ * `Home`/`End`/`Delete`/`Insert`/`PageUp`/`PageDown` are extras beyond the core, included for the
1625
+ * same reason tmux "knows" `Home` even though the core vocabulary does not name it: these are
1626
+ * standard-enough ANSI keys that encoding them costs nothing extra and a caller reaching for one
1627
+ * should not silently get the literal word typed instead.
1628
+ */
1629
+ const WEZTERM_KEY_BYTES = {
1630
+ Up: "\x1B[A",
1631
+ Down: "\x1B[B",
1632
+ Right: "\x1B[C",
1633
+ Left: "\x1B[D",
1634
+ Enter: "\r",
1635
+ Escape: "\x1B",
1636
+ Tab: " ",
1637
+ Space: " ",
1638
+ Backspace: "",
1639
+ "C-c": "",
1640
+ F1: "\x1BOP",
1641
+ F2: "\x1BOQ",
1642
+ F3: "\x1BOR",
1643
+ F4: "\x1BOS",
1644
+ F5: "\x1B[15~",
1645
+ F6: "\x1B[17~",
1646
+ F7: "\x1B[18~",
1647
+ F8: "\x1B[19~",
1648
+ F9: "\x1B[20~",
1649
+ F10: "\x1B[21~",
1650
+ F11: "\x1B[23~",
1651
+ F12: "\x1B[24~",
1652
+ Home: "\x1B[H",
1653
+ End: "\x1B[F",
1654
+ Delete: "\x1B[3~",
1655
+ Insert: "\x1B[2~",
1656
+ PageUp: "\x1B[5~",
1657
+ PageDown: "\x1B[6~"
1658
+ };
1659
+ //#endregion
1660
+ //#region src/backend.ts
1661
+ /**
1662
+ * Backend selection via the two-mode mux probe (`$CYBER_MUX` fast-path/override, else ancestry
1663
+ * discovery from `$$` falling back to the `$TMUX`/`$HERDR_ENV`/`$WEZTERM_PANE` hint when the walk is
1664
+ * inconclusive) — tmux/herdr/wezterm map to their existing adapters; anything else is a clear error.
1665
+ */
1666
+ function selectSessionAdapter(env, exec = realExec) {
1667
+ const probe = probeMultiplexer(exec, env);
1668
+ if (probe.mux === "tmux") return tmuxSessionAdapter;
1669
+ if (probe.mux === "herdr") return herdrSessionAdapter;
1670
+ if (probe.mux === "wezterm") return weztermSessionAdapter;
1671
+ throw new Error("cyber-mux requires a session backend — run inside tmux ($TMUX), herdr ($HERDR_ENV=1), or wezterm ($WEZTERM_PANE set)");
1672
+ }
1673
+ /**
1674
+ * This process's own pane, as something `adapter` can address — `SessionOpenOptions.from`'s intended
1675
+ * argument for a `pane:*` open, so a split lands on the caller rather than on whichever pane the
1676
+ * user is looking at (see `from`'s note for why each backend's default gets that wrong).
1677
+ *
1678
+ * `undefined` when this session is in no pane, or in a pane belonging to a *different* multiplexer
1679
+ * than `adapter` drives — that mismatch is reachable (a `$TMUX_PANE` inherited into a herdr pane,
1680
+ * `$CYBER_MUX` overridden to the other backend), and handing one backend the other's pane id would
1681
+ * turn a self-identity mixup into a split of some unrelated pane. Falling back to the backend's own
1682
+ * default is the conservative answer: still possibly the wrong pane, but never a foreign id.
1683
+ */
1684
+ function callerPane(adapter, env) {
1685
+ const self = currentPane(env);
1686
+ return self && self.mux === adapter.name ? { id: self.pane } : void 0;
1687
+ }
1688
+ //#endregion
1689
+ //#region src/output.ts
1690
+ function printJson(data) {
1691
+ console.log(JSON.stringify(data, null, 2));
1692
+ }
1693
+ function printFields(fields) {
1694
+ const entries = Object.entries(fields).filter(([, v]) => v != null);
1695
+ if (entries.length === 0) return;
1696
+ const width = Math.max(...entries.map(([k]) => k.length));
1697
+ for (const [key, val] of entries) console.log(`${key.padEnd(width)} ${val}`);
1698
+ }
1699
+ /**
1700
+ * Render #9 suggestions as a `help[N]:` block on stdout — inside the structured payload, the stream an
1701
+ * agent reads, not stderr it never does. Each entry is a message line and its command, indented under
1702
+ * it. Prints NOTHING for an empty list: a self-contained result owes no suggestion (#9's
1703
+ * omit-when-self-contained rule), so the block never appears as noise.
1704
+ */
1705
+ function printHelp(entries) {
1706
+ entries.forEach((entry, i) => {
1707
+ console.log(`help[${i}]: ${entry.message}`);
1708
+ console.log(` -> ${entry.command}`);
1709
+ });
1710
+ }
1711
+ function printTable(items, cols) {
1712
+ if (items.length === 0) {
1713
+ console.log("(none)");
1714
+ return;
1715
+ }
1716
+ const widths = cols.map((c) => Math.max(c.label.length, ...items.map((i) => c.get(i).length)));
1717
+ console.log(cols.map((c, i) => c.label.toUpperCase().padEnd(widths[i])).join(" "));
1718
+ console.log(widths.map((w) => "-".repeat(w)).join(" "));
1719
+ for (const item of items) console.log(cols.map((c, i) => c.get(item).padEnd(widths[i])).join(" "));
1720
+ }
1721
+ function getFormat() {
1722
+ const argv = process.argv;
1723
+ const fmtIdx = argv.indexOf("--format");
1724
+ if (fmtIdx !== -1) return argv[fmtIdx + 1];
1725
+ if (argv.includes("--json")) return "json";
1726
+ }
1727
+ /**
1728
+ * Whether the caller asked for machine-readable output. Exported because `output()` is not the only
1729
+ * writer that owes it: a structured ERROR is rendered by `reportError` (`cli-error.ts`) rather than
1730
+ * through `output()`, and it has to honor `--format json` exactly as the success path does. Both write
1731
+ * stdout — AXI's stream for everything the agent consumes, errors included.
1732
+ */
1733
+ function isJsonOutput() {
1734
+ return getFormat() === "json";
1735
+ }
1736
+ function output(data, readable) {
1737
+ if (isJsonOutput()) printJson(data);
1738
+ else readable();
1739
+ }
1740
+ //#endregion
1741
+ //#region src/cli-error.ts
1742
+ /**
1743
+ * The error surface — AXI's #6, made concrete.
1744
+ *
1745
+ * Every failure is a STRUCTURED, CODED error on **stdout**, because stdout is the stream AXI reserves
1746
+ * for what the agent consumes — data, errors and suggestions alike — while stderr is defined as debug
1747
+ * the agent does not read. A report whose whole purpose is telling a caller what went wrong and how to
1748
+ * fix it is the last thing that belongs on the ignored stream. This does not muddy the payload: a verb
1749
+ * either succeeds and writes its result or fails and writes its error, never both, so the exit code
1750
+ * tells the two apart before anything is parsed.
1751
+ *
1752
+ * Three things every error carries, and they are not decoration:
1753
+ * - a **stable `code`** a script matches on, so one failure mode is told from another without parsing
1754
+ * prose (`no-mux` is not `pane-not-found` is not `ambiguous-pane`);
1755
+ * - a **`help`** line naming THIS CLI's command that fixes it — never "see --help", and never a
1756
+ * dependency's own name: an agent handed a raw tmux/herdr diagnostic cannot act on it through
1757
+ * cyber-mux, so a backend's text is TRANSLATED here rather than forwarded;
1758
+ * - an **`exit`** code that separates a usage error (2 — a missing or malformed argument, whose fix is
1759
+ * a different invocation) from a genuine operation failure (1).
1760
+ */
1761
+ var CliError = class extends Error {
1762
+ code;
1763
+ help;
1764
+ exit;
1765
+ extra;
1766
+ constructor(code, message, help, exit, extra) {
1767
+ super(message);
1768
+ this.code = code;
1769
+ this.help = help;
1770
+ this.exit = exit;
1771
+ this.extra = extra;
1772
+ this.name = "CliError";
1773
+ }
1774
+ };
1775
+ /** The stable code the ambiguity error carries, in both formats — what a caller matches on. */
1776
+ const AMBIGUOUS_CODE = "ambiguous-pane";
1777
+ /**
1778
+ * An ambiguous locator — a `CliError` like any other, so the one renderer and the one verb-boundary
1779
+ * catch handle it with no special case, and so a caller sees the same `{ code, help, exit }` shape it
1780
+ * sees for every other failure.
1781
+ *
1782
+ * It is THROWN rather than reported where it is found: reporting in place would mean exiting from
1783
+ * inside `resolveTarget`, and a verb's own catch-all could then flatten an exit-2 ambiguity into an
1784
+ * exit-1 generic failure behind its back. A typed error is what makes the ambiguity visible to every
1785
+ * catch it passes through, so each one rethrows it deliberately instead of swallowing it by accident.
1786
+ *
1787
+ * Each candidate carries its id, its label and its cwd: the id is the RETRY — paste it back and the
1788
+ * ambiguity is gone, since an id outranks every name — and the cwd is what actually tells three panes
1789
+ * all labeled `worker` apart.
1790
+ */
1791
+ var AmbiguousPaneError = class extends CliError {
1792
+ locator;
1793
+ candidates;
1794
+ constructor(locator, candidates) {
1795
+ super(AMBIGUOUS_CODE, `"${locator}" matches ${candidates.length} panes — an id resolves it`, `retry with one of the ids: ${candidates.map((c) => c.id).join(" ")}`, 2, { candidates });
1796
+ this.locator = locator;
1797
+ this.candidates = candidates;
1798
+ this.name = "AmbiguousPaneError";
1799
+ }
1800
+ };
1801
+ /**
1802
+ * The ONE renderer — every coded failure reaches stdout through here, and exits.
1803
+ *
1804
+ * `--format json` emits the machine form: a single `{ error: { code, message, help, ...extra } }`
1805
+ * object, the stable code first, no free-text prose beside it. The readable form leads its human line
1806
+ * with the same `code` token a script branches on — so a person scanning the terminal sees exactly
1807
+ * what a `--format json` consumer matches on — then the `help` line, then (for an ambiguity) one line
1808
+ * per candidate: `<id> <label> <cwd>`, the id-first shape whose first column is the retry.
1809
+ */
1810
+ function reportError(e) {
1811
+ if (isJsonOutput()) console.log(JSON.stringify({ error: {
1812
+ code: e.code,
1813
+ message: e.message,
1814
+ help: e.help,
1815
+ ...e.extra
1816
+ } }, null, 2));
1817
+ else {
1818
+ console.log(`error: ${e.code}: ${e.message}`);
1819
+ console.log(`help: ${e.help}`);
1820
+ const candidates = e.extra?.candidates;
1821
+ if (candidates) for (const c of candidates) console.log(` ${c.id} ${c.label ?? ""} ${c.cwd ?? ""}`);
1822
+ }
1823
+ process.exit(e.exit);
1824
+ }
1825
+ //#endregion
1826
+ //#region src/cli-options.ts
1827
+ /** Output format shared by every command: `text` (human), `json`, or `agent`. */
1828
+ const FORMAT_OPTION = new Option("--format <format>", "Output format").choices([
1829
+ "text",
1830
+ "json",
1831
+ "agent"
1832
+ ]);
1833
+ /**
1834
+ * Accumulate one `--env KEY=VALUE` into the running map. Repeatable: commander calls this once per
1835
+ * flag, threading the previous map through, so `--env A=1 --env B=2` collects both. Rejecting a
1836
+ * malformed pair from HERE — the parser, before the action runs — is what makes "rejected before any
1837
+ * side effect" hold on every verb, worktree-creating ones included. The KEY is everything before the
1838
+ * first `=`, the VALUE everything after it: a value may contain `=` (a URL query, a base64 pad) and a
1839
+ * KEY may not, so the first `=` is the only unambiguous split. A missing `=` is malformed; a present
1840
+ * `=` with nothing after it is a deliberate empty value, not an error.
1841
+ */
1842
+ function collectEnv(pair, previous = {}) {
1843
+ const eq = pair.indexOf("=");
1844
+ if (eq <= 0) throw new InvalidArgumentError(`expected KEY=VALUE, got "${pair}"`);
1845
+ return {
1846
+ ...previous,
1847
+ [pair.slice(0, eq)]: pair.slice(eq + 1)
1848
+ };
1849
+ }
1850
+ /**
1851
+ * `--env KEY=VALUE`, repeatable — the CLI door to the seam's env option, on every verb that opens a
1852
+ * pane. One shared Option so the collector, the split rule, and the rejection are defined once and
1853
+ * every verb inherits them, the way `AT_OPTION`/`LABEL_OPTION` are shared. Conflicts with `--template`,
1854
+ * whose template owns its own panes' env; the two verbs that carry `--template` refuse the pair.
1855
+ */
1856
+ const ENV_OPTION = new Option("--env <pair>", "Environment variable KEY=VALUE (repeatable)").argParser(collectEnv).conflicts("template");
1857
+ /** Placement for a newly opened pane, matching `SessionPlacement`. */
1858
+ const AT_OPTION = new Option("--at <placement>", "Where to place the new pane").choices([
1859
+ "pane:right",
1860
+ "pane:down",
1861
+ "tab",
1862
+ "workspace"
1863
+ ]);
1864
+ /**
1865
+ * Name for whatever `--at` opens. Host-neutral because every backend names every tier: on herdr a
1866
+ * workspace/tab/pane label, on tmux a window name (where `workspace` and `tab` both collapse to a
1867
+ * Window) or a pane title.
1868
+ */
1869
+ const LABEL_OPTION = new Option("--label <label>", "Name for the opened workspace/tab/pane");
1870
+ //#endregion
1871
+ //#region src/template.ts
1872
+ const ARRANGES = [
1873
+ "tiled",
1874
+ "even-horizontal",
1875
+ "even-vertical"
1876
+ ];
1877
+ /**
1878
+ * A template name is `[a-z0-9][a-z0-9-]*` and must equal its file's stem, so a name can never
1879
+ * traverse out of the templates directory. Checked BEFORE any file is read — a name is a lookup key,
1880
+ * not a path, and treating it as one is how `../../../etc/pwd` becomes a read.
1881
+ */
1882
+ const TEMPLATE_NAME = /^[a-z0-9][a-z0-9-]*$/;
1883
+ function isValidTemplateName(name) {
1884
+ return TEMPLATE_NAME.test(name);
1885
+ }
1886
+ /** Parse a template's bytes. Throws on malformed JSON; SCHEMA validity is `validateTemplate`'s job. */
1887
+ function parseTemplate(raw) {
1888
+ try {
1889
+ return JSON.parse(raw);
1890
+ } catch (err) {
1891
+ throw new Error(`template is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1892
+ }
1893
+ }
1894
+ /**
1895
+ * Every validation error, not the first — CI's whole reason to run this is to be told everything
1896
+ * wrong at once. Each error names its own JSON path (`root.second.first.cwd`), so an error points at
1897
+ * a place in the file rather than describing one. An empty array means valid.
1898
+ *
1899
+ * `stem` is the filename's stem when there is a file to compare against; the `name` field must equal
1900
+ * it. The redundancy is the point: a copied template that kept its old name fails loudly.
1901
+ */
1902
+ function validateTemplate(template, stem) {
1903
+ const errors = [];
1904
+ if (typeof template !== "object" || template === null || Array.isArray(template)) return ["template: must be a JSON object"];
1905
+ const t = template;
1906
+ if (t.name === void 0) errors.push("name: required — it must equal the template filename's stem");
1907
+ else if (typeof t.name !== "string") errors.push("name: must be a string");
1908
+ else if (stem !== void 0 && t.name !== stem) errors.push(`name: filename stem is "${stem}" but the name field is "${t.name}" — they must match`);
1909
+ if (t.description !== void 0 && typeof t.description !== "string") errors.push("description: must be a string");
1910
+ const hasRoot = t.root !== void 0;
1911
+ const hasPanes = t.panes !== void 0;
1912
+ const hasTabs = t.tabs !== void 0;
1913
+ const declared = [
1914
+ hasRoot && "root",
1915
+ hasPanes && "panes",
1916
+ hasTabs && "tabs"
1917
+ ].filter((d) => Boolean(d));
1918
+ if (declared.length > 1) errors.push(`root/panes/tabs: exactly one of "root", "panes" or "tabs" may be set — this template sets ${declared.map((d) => `"${d}"`).join(" and ")}`);
1919
+ else if (declared.length === 0) errors.push("root/panes/tabs: exactly one of \"root\", \"panes\" or \"tabs\" must be set — this template sets none");
1920
+ validateTree(t, "", errors);
1921
+ if (hasTabs) if (!Array.isArray(t.tabs)) errors.push("tabs: must be an array of tab objects");
1922
+ else if (t.tabs.length === 0) errors.push("tabs: must name at least one tab — a workspace of no tabs is not one");
1923
+ else t.tabs.forEach((tab, i) => {
1924
+ validateTab(tab, `tabs[${i}]`, errors);
1925
+ });
1926
+ return errors;
1927
+ }
1928
+ /**
1929
+ * One tab: the same `root`/`panes` tree a top-level template declares, plus its own label. Every rule
1930
+ * the template tier holds holds here for the identical reason — hence the shared `validateTree`
1931
+ * rather than a parallel set of checks that could drift.
1932
+ */
1933
+ function validateTab(tab, path, errors) {
1934
+ if (typeof tab !== "object" || tab === null || Array.isArray(tab)) {
1935
+ errors.push(`${path}: must be an object`);
1936
+ return;
1937
+ }
1938
+ const n = tab;
1939
+ if (n.cwd !== void 0) errors.push(`${path}.cwd: a template must never set cwd — pass --cwd at apply time, or use "dir" for a subdirectory under it`);
1940
+ if (n.label !== void 0 && (typeof n.label !== "string" || n.label === "")) errors.push(`${path}.label: must be a non-empty string`);
1941
+ const hasRoot = n.root !== void 0;
1942
+ const hasPanes = n.panes !== void 0;
1943
+ if (hasRoot && hasPanes) errors.push(`${path}: exactly one of "root" or "panes" may be set — this tab sets both`);
1944
+ else if (!hasRoot && !hasPanes) errors.push(`${path}: exactly one of "root" or "panes" must be set — this tab sets neither`);
1945
+ validateTree(n, path, errors);
1946
+ }
1947
+ /**
1948
+ * The `root` / `panes` / `arrange` triple, wherever it sits. `path` is `''` at the template tier and
1949
+ * `tabs[i]` inside a tab, so an error points at a place in the file either way. Whether exactly one of
1950
+ * the two spellings is present is the CALLER's check — the template tier weighs `tabs` in that choice
1951
+ * and a tab does not.
1952
+ */
1953
+ function validateTree(t, path, errors) {
1954
+ const at = (key) => path === "" ? key : `${path}.${key}`;
1955
+ if (t.arrange !== void 0 && (typeof t.arrange !== "string" || !ARRANGES.includes(t.arrange))) errors.push(`${at("arrange")}: must be one of ${ARRANGES.join(", ")}`);
1956
+ if (t.root !== void 0) validateNode(t.root, at("root"), errors);
1957
+ if (t.panes !== void 0) if (!Array.isArray(t.panes)) errors.push(`${at("panes")}: must be an array of pane objects`);
1958
+ else if (t.panes.length === 0) errors.push(`${at("panes")}: must name at least one pane`);
1959
+ else t.panes.forEach((pane, i) => {
1960
+ validatePaneFields(pane, `${at("panes")}[${i}]`, errors);
1961
+ });
1962
+ }
1963
+ function validateNode(node, path, errors) {
1964
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
1965
+ errors.push(`${path}: must be an object with a "type" of "pane" or "split"`);
1966
+ return;
1967
+ }
1968
+ const n = node;
1969
+ if (n.type === "pane") {
1970
+ validatePaneFields(n, path, errors);
1971
+ return;
1972
+ }
1973
+ if (n.type !== "split") {
1974
+ errors.push(`${path}.type: must be "pane" or "split"`);
1975
+ return;
1976
+ }
1977
+ if (n.direction !== "right" && n.direction !== "down") errors.push(`${path}.direction: must be "right" or "down"`);
1978
+ if (n.ratio !== void 0) {
1979
+ if (typeof n.ratio !== "number" || !Number.isFinite(n.ratio) || n.ratio <= 0 || n.ratio >= 1) errors.push(`${path}.ratio: must be a number strictly between 0 and 1 — got ${JSON.stringify(n.ratio)}`);
1980
+ }
1981
+ if (n.first === void 0) errors.push(`${path}.first: required`);
1982
+ else validateNode(n.first, `${path}.first`, errors);
1983
+ if (n.second === void 0) errors.push(`${path}.second: required`);
1984
+ else validateNode(n.second, `${path}.second`, errors);
1985
+ }
1986
+ function validatePaneFields(pane, path, errors) {
1987
+ if (typeof pane !== "object" || pane === null || Array.isArray(pane)) {
1988
+ errors.push(`${path}: must be an object`);
1989
+ return;
1990
+ }
1991
+ const p = pane;
1992
+ if (p.cwd !== void 0) errors.push(`${path}.cwd: a template must never set cwd — pass --cwd at apply time, or use "dir" for a subdirectory under it`);
1993
+ if (p.label !== void 0 && (typeof p.label !== "string" || p.label === "")) errors.push(`${path}.label: must be a non-empty string`);
1994
+ if (p.command !== void 0 && typeof p.command !== "string") errors.push(`${path}.command: must be a string`);
1995
+ if (p.env !== void 0) {
1996
+ if (typeof p.env !== "object" || p.env === null || Array.isArray(p.env)) errors.push(`${path}.env: must be an object of string values`);
1997
+ else for (const [key, value] of Object.entries(p.env)) if (typeof value !== "string") errors.push(`${path}.env.${key}: must be a string`);
1998
+ }
1999
+ if (p.dir !== void 0) {
2000
+ if (typeof p.dir !== "string" || p.dir === "") errors.push(`${path}.dir: must be a non-empty string`);
2001
+ else if (dirEscapes(p.dir)) errors.push(`${path}.dir: must be a relative subdirectory under the apply-time target — "${p.dir}" escapes it`);
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Whether a `dir` can reach outside the apply-time target. Absolute is rejected because a
2006
+ * machine-specific path must never reach a template by any road; `..` is rejected because it is the
2007
+ * other road to the same place. Checked on the RAW string as well as the normalized one — a `..` that
2008
+ * cancels out (`packages/../../outside` normalizes past the root, but `a/../b` does not) is still an
2009
+ * author saying something they did not mean.
2010
+ */
2011
+ function dirEscapes(dir) {
2012
+ if (isAbsolute(dir)) return true;
2013
+ if (dir.split(/[/\\]+/).includes("..")) return true;
2014
+ return normalize(dir).startsWith("..");
2015
+ }
2016
+ /**
2017
+ * The tree a `TemplateTree` describes, whichever form it was written in — the ONE place `panes`/`arrange`
2018
+ * becomes a tree, so `template show --desugar` and the apply walk can never disagree about what a flat
2019
+ * template means.
2020
+ *
2021
+ * It takes either carrier of a `TemplateTree`, so a `TabNode` and a single-tab `Template` resolve
2022
+ * through THIS function rather than through two that happen to agree today: the sugar is a property of
2023
+ * a pane pool, not of where the pool sits, so a tab of 3 panes means what a top-level pool of 3 panes
2024
+ * means. One desugarer, one answer.
2025
+ */
2026
+ function resolveTree(tree) {
2027
+ if (tree.root) return tree.root;
2028
+ return desugar(tree.panes ?? [], tree.arrange ?? "tiled");
2029
+ }
2030
+ /**
2031
+ * Expand the flat sugar into the canonical tree. A pure function of `panes.length` and `arrange`
2032
+ * ALONE — no backend, no region size — which is exactly what lets `show --desugar` print the tree
2033
+ * apply will build, and what makes one template mean one geometry everywhere.
2034
+ *
2035
+ * tmux's native `select-template tiled` is deliberately NOT used even though it exists and would be one
2036
+ * call: it implements tmux's own grid algorithm, herdr has no equivalent, and reaching for it would
2037
+ * mean the same template producing a visibly different geometry per backend — and a third on
2038
+ * whatever backend comes next. Owning the desugaring is what makes a backend-agnostic schema worth
2039
+ * having, and it costs exactly one saved call.
2040
+ */
2041
+ function desugar(panes, arrange) {
2042
+ if (panes.length === 0) throw new Error("a flat template must name at least one pane");
2043
+ const leaves = panes.map(toPaneNode$1);
2044
+ if (arrange === "even-horizontal") return comb(leaves, "right");
2045
+ if (arrange === "even-vertical") return comb(leaves, "down");
2046
+ return tiled(leaves);
2047
+ }
2048
+ function toPaneNode$1(pane) {
2049
+ const node = { type: "pane" };
2050
+ if (pane.label !== void 0) node.label = pane.label;
2051
+ if (pane.command !== void 0) node.command = pane.command;
2052
+ if (pane.env !== void 0) node.env = pane.env;
2053
+ if (pane.dir !== void 0) node.dir = pane.dir;
2054
+ return node;
2055
+ }
2056
+ /**
2057
+ * The even comb: split at `1/n`, then `1/(n-1)`, … so all `n` regions end EQUAL.
2058
+ *
2059
+ * The ratios are the whole point and the easy thing to get wrong. Splitting evenly at `0.5` each time
2060
+ * would yield 1/2, 1/4, 1/4 — a comb that looks like a row and is not one. Peeling `1/n` off the
2061
+ * front leaves `(n-1)/n` for the rest, which the next `1/(n-1)` divides into another exact `1/n`.
2062
+ */
2063
+ function comb(nodes, direction) {
2064
+ const [head, ...rest] = nodes;
2065
+ if (rest.length === 0) return head;
2066
+ return {
2067
+ type: "split",
2068
+ direction,
2069
+ ratio: 1 / nodes.length,
2070
+ first: head,
2071
+ second: comb(rest, direction)
2072
+ };
2073
+ }
2074
+ /**
2075
+ * A balanced grid: `ceil(sqrt(n))` columns laid left-to-right, each column an even stack. Both axes
2076
+ * are the same even comb, so the geometry is exact rather than approximate at every `n`.
2077
+ *
2078
+ * For `n = 4` this is one `right` at `0.5` with a `down` at `0.5` in each half — a true 2x2. `n = 1`
2079
+ * falls out as the bare pane with no split at all, rather than being special-cased.
2080
+ */
2081
+ function tiled(leaves) {
2082
+ if (leaves.length === 1) return leaves[0];
2083
+ return comb(distribute(leaves, Math.ceil(Math.sqrt(leaves.length))).map((column) => comb(column, "down")), "right");
2084
+ }
2085
+ /** Split `items` into `groups` contiguous chunks as evenly as possible, remainder to the front. */
2086
+ function distribute(items, groups) {
2087
+ const out = [];
2088
+ const base = Math.floor(items.length / groups);
2089
+ let remainder = items.length % groups;
2090
+ let index = 0;
2091
+ for (let g = 0; g < groups; g++) {
2092
+ const size = base + (remainder > 0 ? 1 : 0);
2093
+ if (remainder > 0) remainder--;
2094
+ out.push(items.slice(index, index + size));
2095
+ index += size;
2096
+ }
2097
+ return out;
2098
+ }
2099
+ /** Every pane in the tree, in template order — a depth-first walk taking `first` before `second`. */
2100
+ function collectPanes(node, acc = []) {
2101
+ if (node.type === "pane") acc.push(node);
2102
+ else {
2103
+ collectPanes(node.first, acc);
2104
+ collectPanes(node.second, acc);
2105
+ }
2106
+ return acc;
2107
+ }
2108
+ /**
2109
+ * The pane that ends up on a subtree's EXISTING region pane — follow `first` down, since `first`
2110
+ * always inherits the pane a split was made from. This is what tells the walk whose `env` and `dir`
2111
+ * a split must carry: the new pane a split creates is the region for `second`, and the leaf that
2112
+ * ultimately sits on it is `firstPane(second)`.
2113
+ */
2114
+ function firstPane(node) {
2115
+ return node.type === "pane" ? node : firstPane(node.first);
2116
+ }
2117
+ //#endregion
2118
+ //#region src/template-capture.ts
2119
+ const right = (rect) => rect.x + rect.width;
2120
+ const bottom = (rect) => rect.y + rect.height;
2121
+ const HORIZONTAL = {
2122
+ direction: "right",
2123
+ start: (r) => r.x,
2124
+ end: right
2125
+ };
2126
+ const VERTICAL = {
2127
+ direction: "down",
2128
+ start: (r) => r.y,
2129
+ end: bottom
2130
+ };
2131
+ /**
2132
+ * The lowest cut on this axis that separates the panes cleanly, or `undefined` if none does.
2133
+ *
2134
+ * Taking the LOWEST rather than any is what produces a right-comb for an n-ary row: three panes side
2135
+ * by side cut first into `[a][b c]`, then `[b][c]` — the exact tree `desugar`'s `comb` emits for
2136
+ * `arrange: even-horizontal`, reached from the opposite direction.
2137
+ *
2138
+ * A candidate is any pane's start edge. It separates cleanly when every pane lies wholly before it
2139
+ * or wholly after it, and both sides have something in them.
2140
+ */
2141
+ function findCut(panes, axis) {
2142
+ const candidates = [...new Set(panes.map((p) => axis.start(p.rect)))].sort((a, b) => a - b);
2143
+ for (const at of candidates) {
2144
+ const first = panes.filter((p) => axis.end(p.rect) <= at);
2145
+ const second = panes.filter((p) => axis.start(p.rect) >= at);
2146
+ if (first.length === 0 || second.length === 0) continue;
2147
+ if (first.length + second.length !== panes.length) continue;
2148
+ return {
2149
+ direction: axis.direction,
2150
+ ratio: ratioOf(panes, second, axis),
2151
+ first,
2152
+ second
2153
+ };
2154
+ }
2155
+ }
2156
+ /**
2157
+ * The fraction of the split region kept by `first` — the schema's `ratio`.
2158
+ *
2159
+ * Measured as the COMPLEMENT of what `second` occupies, over the whole region: `1 - second/total`.
2160
+ * The obvious `first / (first + second)` is subtly wrong on any backend that draws a divider, and
2161
+ * the arithmetic says why — tmux splitting a 50-row region reports 34 + 15, with the 51st row eaten
2162
+ * by the divider. `first / (first + second)` reads 34/49 = 0.69; the true split was 0.7, and the
2163
+ * divider row belongs to neither pane's height while still costing the region a row.
2164
+ *
2165
+ * Taking the complement puts that row back where the backend's own arithmetic puts it: tmux's `-l`
2166
+ * sizes the NEW pane, so `second` is exactly the fraction asked for and `first` keeps the rest,
2167
+ * divider included. That reads 1 - 15/50 = 0.7 — the number the split was actually made with. On a
2168
+ * backend with no divider (herdr) the two formulas agree, so nothing is traded for the fix.
2169
+ *
2170
+ * Both checked against live binaries: this recovers tmux's `-l 40%`/`-l 30%` splits as 0.6/0.7
2171
+ * exactly, and reproduces herdr's to within the cell it rounds to.
2172
+ */
2173
+ function ratioOf(all, second, axis) {
2174
+ const total = extent(all, axis);
2175
+ if (total <= 0) return .5;
2176
+ return 1 - extent(second, axis) / total;
2177
+ }
2178
+ /** How far a group of panes reaches along an axis — its bounding box on that axis. */
2179
+ function extent(panes, axis) {
2180
+ const starts = panes.map((p) => axis.start(p.rect));
2181
+ const ends = panes.map((p) => axis.end(p.rect));
2182
+ return Math.max(...ends) - Math.min(...starts);
2183
+ }
2184
+ /**
2185
+ * Cut the region into a binary tree, recursively.
2186
+ *
2187
+ * **`right` is tried before `down`, and the order is load-bearing on a grid.** A 2x2 is genuinely
2188
+ * ambiguous — cutting it vertically first and horizontally first both describe the same screen, and
2189
+ * neither is more true. Columns-then-rows is the tie-break because that is what `desugar`'s `tiled`
2190
+ * emits, so a tiled pool exports back as the tree it was built from rather than its transpose.
2191
+ *
2192
+ * A region no cut separates cannot come out of a multiplexer: both backends build regions BY
2193
+ * splitting, so every region they can report is guillotine-cuttable by construction. Reaching the
2194
+ * throw means the geometry did not come from where we think it did — which is worth saying loudly
2195
+ * rather than papering over with a tree that misplaces the user's panes.
2196
+ */
2197
+ function partition(panes) {
2198
+ if (panes.length === 1) return {
2199
+ type: "pane",
2200
+ pane: panes[0]
2201
+ };
2202
+ const cut = findCut(panes, HORIZONTAL) ?? findCut(panes, VERTICAL);
2203
+ if (!cut) throw new Error(`this region's panes do not form a splittable tree (${panes.length} panes: ${panes.map((p) => p.id).join(", ")}) — export can only capture a region built by splitting`);
2204
+ const node = {
2205
+ type: "split",
2206
+ direction: cut.direction,
2207
+ first: partition(cut.first),
2208
+ second: partition(cut.second)
2209
+ };
2210
+ const ratio = roundRatio(cut.ratio);
2211
+ if (ratio !== .5) node.ratio = ratio;
2212
+ return node;
2213
+ }
2214
+ /**
2215
+ * Two decimals, and clamped strictly inside `(0, 1)`.
2216
+ *
2217
+ * Two because the emitted template is meant to be READ and edited: a 3-pane row wants `0.33`, not
2218
+ * `0.33167`, and the cell it costs is invisible. The clamp is the guard on a degenerate capture — a
2219
+ * pane one cell wide in a wide region rounds to `0`, which `validateTemplate` rejects outright, so an
2220
+ * export of a real screen would emit a template that fails its own validator.
2221
+ */
2222
+ function roundRatio(ratio) {
2223
+ const rounded = Math.round(ratio * 100) / 100;
2224
+ return Math.min(.99, Math.max(.01, rounded));
2225
+ }
2226
+ /**
2227
+ * The `dir` a pane's cwd becomes: relative to the root, or `undefined` when it IS the root or sits
2228
+ * outside it. Apply's injection run backwards — apply joins `cwd + dir`, so export subtracts.
2229
+ *
2230
+ * The schema forbids `cwd` outright, so a pane outside the root has nowhere to put its location and
2231
+ * genuinely loses it. That is reported as a warning rather than dropped in silence, and never
2232
+ * emitted as a `..` path: `dir` must stay under the apply-time target, so a template that escaped it
2233
+ * would fail validation on the way back in.
2234
+ */
2235
+ function toDir(paneCwd, rootCwd) {
2236
+ if (!paneCwd || !rootCwd) return { outside: false };
2237
+ const rel = relative(rootCwd, paneCwd);
2238
+ if (rel === "") return { outside: false };
2239
+ if (rel.startsWith("..") || rel.split(sep).includes("..")) return { outside: true };
2240
+ return {
2241
+ dir: rel,
2242
+ outside: false
2243
+ };
2244
+ }
2245
+ /** The pane sitting on the region's own root — follow `first` down, exactly as `firstPane` does. */
2246
+ function rootOf(tree) {
2247
+ return tree.type === "pane" ? tree.pane : rootOf(tree.first);
2248
+ }
2249
+ /**
2250
+ * Capture a region into a template.
2251
+ *
2252
+ * The root pane's cwd becomes the template's implicit target — every other pane's `dir` is measured
2253
+ * from it — because that is precisely what apply injects `--cwd` as. A pane elsewhere on the disk
2254
+ * cannot be expressed and says so in `warnings`.
2255
+ */
2256
+ function captureTemplate(panes, opts) {
2257
+ if (panes.length === 0) throw new Error("a capture needs at least one pane — this region reported none");
2258
+ const tree = partition(panes);
2259
+ const ctx = context(rootOf(tree).cwd);
2260
+ const template = shell(opts);
2261
+ template.root = convert(tree, ctx);
2262
+ return {
2263
+ template,
2264
+ warnings: ctx.warnings
2265
+ };
2266
+ }
2267
+ /**
2268
+ * Capture a whole workspace into a `tabs` template — the exact inverse of the tabs walk, and
2269
+ * `captureTemplate` one level up rather than a second derivation: each tab's tree comes off the SAME
2270
+ * `partition`, because a tab is a region and the geometry rules cannot depend on how many of them
2271
+ * there are.
2272
+ *
2273
+ * One thing is workspace-WIDE rather than per-tab, and it follows from what the schema already says:
2274
+ * the target is the FIRST tab's root pane, because that is the pane apply's `--cwd` opens the
2275
+ * workspace at, so every tab's `dir` is measured from that one root.
2276
+ */
2277
+ function captureWorkspaceTemplate(tabs, opts) {
2278
+ if (tabs.length === 0) throw new Error("a workspace capture needs at least one tab — this workspace reported none");
2279
+ const trees = tabs.map((tab) => {
2280
+ if (tab.panes.length === 0) throw new Error(`a capture needs at least one pane — tab ${tab.id} reported none`);
2281
+ return partition(tab.panes);
2282
+ });
2283
+ const ctx = context(rootOf(trees[0]).cwd);
2284
+ const template = shell(opts);
2285
+ template.tabs = tabs.map((tab, index) => {
2286
+ const node = {};
2287
+ if (tab.label) node.label = tab.label;
2288
+ node.root = convert(trees[index], ctx);
2289
+ return node;
2290
+ });
2291
+ return {
2292
+ template,
2293
+ warnings: ctx.warnings
2294
+ };
2295
+ }
2296
+ /** The template every capture starts from — the fields that owe nothing to the geometry. */
2297
+ function shell(opts) {
2298
+ const template = { name: opts.name };
2299
+ if (opts.description) template.description = opts.description;
2300
+ return template;
2301
+ }
2302
+ function context(rootCwd) {
2303
+ return {
2304
+ rootCwd,
2305
+ warnings: []
2306
+ };
2307
+ }
2308
+ function toPaneNode(pane, ctx) {
2309
+ const node = { type: "pane" };
2310
+ if (pane.label) node.label = pane.label;
2311
+ const { dir, outside } = toDir(pane.cwd, ctx.rootCwd);
2312
+ if (dir) node.dir = dir;
2313
+ if (outside) ctx.warnings.push(`pane ${pane.id}${pane.label ? ` ("${pane.label}")` : ""} runs in ${pane.cwd}, which is not under the captured root ${ctx.rootCwd} — a template cannot pin a directory, so this pane is captured without one`);
2314
+ return node;
2315
+ }
2316
+ /**
2317
+ * A partition into schema nodes. `command` is never emitted and there is no branch here that could
2318
+ * emit one: no multiplexer reports the command a pane was launched with, so a capture at any tier is
2319
+ * a DRAFT with `command` left for the author.
2320
+ */
2321
+ function convert(node, ctx) {
2322
+ if (node.type === "pane") return toPaneNode(node.pane, ctx);
2323
+ const split = {
2324
+ type: "split",
2325
+ direction: node.direction,
2326
+ first: convert(node.first, ctx),
2327
+ second: convert(node.second, ctx)
2328
+ };
2329
+ if (node.ratio !== void 0) split.ratio = node.ratio;
2330
+ return split;
2331
+ }
2332
+ //#endregion
2333
+ //#region src/template-session.ts
2334
+ /**
2335
+ * A walk that threw partway. Carries the manifest of what WAS built, because apply does not roll
2336
+ * back: rolling back would mean killing panes, and a kill is not obviously safer than a half-built
2337
+ * template the caller can see and finish. This is the price of owning the engine rather than
2338
+ * delegating to an atomic tree-apply, and it is paid uniformly — a guarantee only herdr could make
2339
+ * is not a guarantee cyber-mux can offer.
2340
+ */
2341
+ var TemplateApplyError = class extends Error {
2342
+ manifest;
2343
+ constructor(message, manifest) {
2344
+ super(message);
2345
+ this.manifest = manifest;
2346
+ this.name = "TemplateApplyError";
2347
+ }
2348
+ };
2349
+ /** A tab's bookkeeping, with its root pane already open and its root leaf pinned to that pane. */
2350
+ function tabState(tree, index, root, rootDir, rootEnvHonored) {
2351
+ const rootLeaf = firstPane(tree);
2352
+ return {
2353
+ tree,
2354
+ index,
2355
+ root,
2356
+ rootDir,
2357
+ rootEnvHonored,
2358
+ ordered: collectPanes(tree),
2359
+ rootLeaf,
2360
+ paneOf: /* @__PURE__ */ new Map([[rootLeaf, root.id]])
2361
+ };
2362
+ }
2363
+ /** A pane's resolved cwd: the apply-time target, joined with the node's relative `dir`. */
2364
+ function resolveDir(cwd, dir) {
2365
+ return dir ? join(cwd, dir) : cwd;
2366
+ }
2367
+ /**
2368
+ * Every `dir` the template names, checked against the REAL target before anything is opened. A
2369
+ * branch that predates a directory is a real case, so the error names the pane and the resolved path
2370
+ * rather than just failing a mkdir somewhere.
2371
+ *
2372
+ * Up front, not per-pane-at-birth: a predictable error should not cost a half-built pool.
2373
+ */
2374
+ function assertTemplateDirs(tree, cwd, dirExists) {
2375
+ for (const pane of collectPanes(tree)) {
2376
+ if (pane.dir === void 0) continue;
2377
+ const resolved = resolveDir(cwd, pane.dir);
2378
+ if (!dirExists(resolved)) throw new Error(`template pane "${pane.label ?? "(unlabeled)"}": directory does not exist — ${resolved}`);
2379
+ }
2380
+ }
2381
+ /**
2382
+ * Open a region and build the template inside it — `open --template`.
2383
+ *
2384
+ * The region opens BLANK (no `launch`) and its pane becomes the tree's root region: not a wasted
2385
+ * pane to close, but the pane the walk splits INTO. That is why nothing is launched here — the
2386
+ * template owns what runs.
2387
+ *
2388
+ * The manifest's `workspace` is whatever the region's own `open` landed in — the workspace it
2389
+ * created at the default `workspace` placement, or the one it landed inside at a `tab`/`pane:*`
2390
+ * placement. `null` only when the backend has no workspace tier (tmux) and so had nothing to report.
2391
+ * This is occupancy, not a worktree binding: `open` groups no repo, and a caller must not read a
2392
+ * workspace here as evidence that it did.
2393
+ */
2394
+ function openTemplate(exec, adapter, template, opts) {
2395
+ if (template.tabs) return openTabsTemplate(exec, adapter, template, template.tabs, opts);
2396
+ const tree = resolveTree(template);
2397
+ assertTemplateDirs(tree, opts.cwd, opts.dirExists);
2398
+ const rootLeaf = firstPane(tree);
2399
+ const rootDir = resolveDir(opts.cwd, rootLeaf.dir);
2400
+ const root = adapter.open(exec, {
2401
+ cwd: rootDir,
2402
+ at: opts.at ?? "workspace",
2403
+ label: opts.label,
2404
+ env: rootLeaf.env,
2405
+ from: opts.from
2406
+ });
2407
+ return walk(tabState(tree, null, root, rootDir, true), {
2408
+ exec,
2409
+ adapter,
2410
+ cwd: opts.cwd,
2411
+ name: template.name,
2412
+ workspace: root.workspace ?? null,
2413
+ dirExists: opts.dirExists,
2414
+ warnedRatio: false
2415
+ });
2416
+ }
2417
+ /**
2418
+ * The workspace label a tabs apply groups under: what the caller asked for, or the template's own
2419
+ * name. Never shortened, anywhere — it is the label the caller already chose, so the caller owns its
2420
+ * length, and not shortening is what makes a collision between two workspaces that shorten alike
2421
+ * impossible rather than merely handled.
2422
+ */
2423
+ function workspaceLabelOf(template, label) {
2424
+ return label ?? template.name;
2425
+ }
2426
+ /**
2427
+ * A workspace of N tabs — the single-tab walk, wrapped, with the inner walk unchanged and run once
2428
+ * per tab. Every later tab opens INSIDE the workspace at the `tab` placement; no tab is ever a split
2429
+ * of another tab's pane, which is the whole difference between a workspace of tabs and one tab of
2430
+ * panes.
2431
+ *
2432
+ * `firstTab` is the ONE thing the two routes differ in, and the reason they share this walk rather
2433
+ * than owning two that could drift: `open --template` opens the workspace and hands back its region,
2434
+ * while `worktree add --template` already HAS one — the worktree's own workspace, which that route
2435
+ * forced the placement for — so the first tab builds into it rather than opening a second.
2436
+ *
2437
+ * Every tab is opened and every split built BEFORE the first command is submitted — the single-tab
2438
+ * ordering, scaled: a split lands mid-render if it targets a pane already running an interactive
2439
+ * agent, and a tab is opened blank for exactly the reason a region is.
2440
+ */
2441
+ function walkTabs(ctx, tabs, trees, workspaceLabel, group, firstTab) {
2442
+ const built = [];
2443
+ try {
2444
+ trees.forEach((tree, index) => {
2445
+ let opened;
2446
+ if (index === 0) {
2447
+ const first = firstTab();
2448
+ opened = first.root;
2449
+ built.push(tabState(tree, 0, first.root, first.rootDir, first.rootEnvHonored));
2450
+ const name = tabLabelFor(ctx, tabs[0], workspaceLabel);
2451
+ if (name !== void 0) ctx.adapter.rename(ctx.exec, { id: opened.tab }, "tab", name);
2452
+ } else {
2453
+ const rootLeaf = firstPane(tree);
2454
+ const rootDir = resolveDir(ctx.cwd, rootLeaf.dir);
2455
+ opened = ctx.adapter.open(ctx.exec, {
2456
+ cwd: rootDir,
2457
+ at: "tab",
2458
+ label: tabLabelFor(ctx, tabs[index], workspaceLabel),
2459
+ env: rootLeaf.env
2460
+ });
2461
+ built.push(tabState(tree, index, opened, rootDir, true));
2462
+ }
2463
+ ctx.adapter.group(ctx.exec, { id: opened.tab }, group, tabs[index].label);
2464
+ buildGeometry(built[index], ctx);
2465
+ });
2466
+ } catch (err) {
2467
+ throw new TemplateApplyError(err instanceof Error ? err.message : String(err), report(ctx, built));
2468
+ }
2469
+ submitCommands(built, ctx);
2470
+ return report(ctx, built);
2471
+ }
2472
+ /** `open --template` with a tabs template: the first tab opens the workspace the rest live in. */
2473
+ function openTabsTemplate(exec, adapter, template, tabs, opts) {
2474
+ const trees = tabs.map((tab) => resolveTree(tab));
2475
+ for (const tree of trees) assertTemplateDirs(tree, opts.cwd, opts.dirExists);
2476
+ const group = randomUUID();
2477
+ const workspaceLabel = workspaceLabelOf(template, opts.label);
2478
+ const ctx = {
2479
+ exec,
2480
+ adapter,
2481
+ cwd: opts.cwd,
2482
+ name: template.name,
2483
+ workspace: null,
2484
+ dirExists: opts.dirExists,
2485
+ warnedRatio: false
2486
+ };
2487
+ return walkTabs(ctx, tabs, trees, workspaceLabel, group, () => {
2488
+ const rootLeaf = firstPane(trees[0]);
2489
+ const rootDir = resolveDir(opts.cwd, rootLeaf.dir);
2490
+ const opened = adapter.open(exec, {
2491
+ cwd: rootDir,
2492
+ at: opts.at ?? "workspace",
2493
+ label: workspaceLabel,
2494
+ env: rootLeaf.env,
2495
+ from: opts.from
2496
+ });
2497
+ ctx.workspace = opened.workspace ?? null;
2498
+ return {
2499
+ root: opened,
2500
+ rootDir,
2501
+ rootEnvHonored: true
2502
+ };
2503
+ });
2504
+ }
2505
+ /**
2506
+ * A tab's label, as a human reads it.
2507
+ *
2508
+ * Where the backend has no workspace tier (tmux, which collapses workspace and tab onto one Window),
2509
+ * the workspace is carried into the label — `<workspace> - <tab>` — because the template's tabs would
2510
+ * otherwise land as an unlabeled pile with nothing marking them as one pool. Where the backend HAS the
2511
+ * tier (herdr), its UI already groups by the real workspace label, so a prefix would be redundant
2512
+ * noise and the tab carries its own label alone. The concept maps onto what the backend actually has.
2513
+ *
2514
+ * The workspace label goes in whole — never shortened. It is the label the caller already chose, so
2515
+ * the caller owns its length, and not shortening is what makes a collision between two workspaces that
2516
+ * shorten alike impossible rather than merely handled.
2517
+ *
2518
+ * This is the human's carrier only. The machine reads `workspaceGroup`, never this — the label is
2519
+ * ambiguous under every split rule ("acme - beta - main"), so it is written and never parsed back.
2520
+ */
2521
+ function tabLabelFor(ctx, tab, workspaceLabel) {
2522
+ if (tab.label === void 0) return void 0;
2523
+ if (ctx.workspace !== null) return tab.label;
2524
+ return `${workspaceLabel} - ${tab.label}`;
2525
+ }
2526
+ /**
2527
+ * The pane that will sit on a region's own root pane — whoever opens that region must carry this
2528
+ * pane's `env` (and, where it can, its `dir`), because no split ever births it.
2529
+ *
2530
+ * For a tabs template that is the FIRST tab's root leaf: the first tab is the one built into the
2531
+ * region the caller opens, and every later tab opens its own space (carrying its own root leaf's env
2532
+ * at that open). Resolving the template itself here would desugar a `panes` list a tabs template does
2533
+ * not have.
2534
+ */
2535
+ function templateRootPane(template) {
2536
+ return firstPane(resolveTree(template.tabs ? template.tabs[0] : template));
2537
+ }
2538
+ /**
2539
+ * Build a template inside a region someone else already opened — `worktree add --template`, where the
2540
+ * worktree's own workspace IS the region and its root pane is the tree's root.
2541
+ */
2542
+ function applyTemplateToRegion(exec, adapter, template, opts) {
2543
+ if (template.tabs) return applyTabsToRegion(exec, adapter, template, template.tabs, opts);
2544
+ const tree = resolveTree(template);
2545
+ assertTemplateDirs(tree, opts.cwd, opts.dirExists);
2546
+ const rootLeaf = firstPane(tree);
2547
+ if (rootLeaf.dir !== void 0) process.stderr.write(`the template's root pane "${rootLeaf.label ?? "(unlabeled)"}" cannot start in "${rootLeaf.dir}" — the region opens at ${opts.cwd}\n`);
2548
+ return walk(tabState(tree, null, opts.root, opts.cwd, opts.rootEnvHonored), {
2549
+ exec,
2550
+ adapter,
2551
+ cwd: opts.cwd,
2552
+ name: template.name,
2553
+ workspace: opts.workspace,
2554
+ dirExists: opts.dirExists,
2555
+ warnedRatio: false
2556
+ });
2557
+ }
2558
+ /**
2559
+ * `worktree add --template` with a tabs template: the workspace already EXISTS — that route forces the
2560
+ * `workspace` placement and opened one for the worktree — so a set of tabs has somewhere to live and
2561
+ * needs no second workspace. The first tab is built INTO that region; every later tab opens as a tab
2562
+ * in it. That one difference is the whole of what separates this route from `open --template`.
2563
+ */
2564
+ function applyTabsToRegion(exec, adapter, template, tabs, opts) {
2565
+ const trees = tabs.map((tab) => resolveTree(tab));
2566
+ for (const tree of trees) assertTemplateDirs(tree, opts.cwd, opts.dirExists);
2567
+ const ctx = {
2568
+ exec,
2569
+ adapter,
2570
+ cwd: opts.cwd,
2571
+ name: template.name,
2572
+ workspace: opts.workspace,
2573
+ dirExists: opts.dirExists,
2574
+ warnedRatio: false
2575
+ };
2576
+ const rootLeaf = firstPane(trees[0]);
2577
+ if (rootLeaf.dir !== void 0) process.stderr.write(`the template's root pane "${rootLeaf.label ?? "(unlabeled)"}" cannot start in "${rootLeaf.dir}" — the region opens at ${opts.cwd}\n`);
2578
+ return walkTabs(ctx, tabs, trees, workspaceLabelOf(template, opts.label), randomUUID(), () => ({
2579
+ root: opts.root,
2580
+ rootDir: opts.cwd,
2581
+ rootEnvHonored: opts.rootEnvHonored
2582
+ }));
2583
+ }
2584
+ /**
2585
+ * The walk: geometry depth-first against NAMED panes, then every command, last.
2586
+ *
2587
+ * **Geometry before commands is deliberate ordering, not incidental.** `open`'s `launch` couples
2588
+ * creation to launching, so reusing it would mean splitting a pane already running an interactive
2589
+ * agent — the split lands mid-render, and the ratio is computed against a pane whose child is
2590
+ * reflowing. Opening every pane blank first makes the whole geometry phase side-effect-free from the
2591
+ * agent's point of view.
2592
+ */
2593
+ function walk(tab, ctx) {
2594
+ const tabs = [tab];
2595
+ try {
2596
+ buildGeometry(tab, ctx);
2597
+ } catch (err) {
2598
+ throw new TemplateApplyError(err instanceof Error ? err.message : String(err), report(ctx, tabs));
2599
+ }
2600
+ submitCommands(tabs, ctx);
2601
+ return report(ctx, tabs);
2602
+ }
2603
+ /**
2604
+ * One tab's geometry: depth-first against NAMED panes, opening every pane blank. Submits nothing —
2605
+ * the caller does that once EVERY tab is built, which is what lets the multi-tab walk hold the same
2606
+ * "no split ever lands on a pane mid-render" guarantee the single-tab walk holds.
2607
+ */
2608
+ function buildGeometry(tab, ctx) {
2609
+ const sizeSplit = (ratio) => {
2610
+ if (ratio == null) return void 0;
2611
+ if (ctx.adapter.canSizeSplits) return ratio;
2612
+ if (!ctx.warnedRatio) {
2613
+ ctx.warnedRatio = true;
2614
+ process.stderr.write(`${ctx.adapter.name} cannot size a split — every ratio in this template takes its default\n`);
2615
+ }
2616
+ };
2617
+ const build = (node, paneId) => {
2618
+ if (node.type === "pane") return;
2619
+ const born = firstPane(node.second);
2620
+ const created = ctx.adapter.open(ctx.exec, {
2621
+ cwd: resolveDir(ctx.cwd, born.dir),
2622
+ at: node.direction === "down" ? "pane:down" : "pane:right",
2623
+ from: { id: paneId },
2624
+ ratio: sizeSplit(node.ratio),
2625
+ env: born.env,
2626
+ label: born.label
2627
+ });
2628
+ tab.paneOf.set(born, created.id);
2629
+ build(node.first, paneId);
2630
+ build(node.second, created.id);
2631
+ };
2632
+ build(tab.tree, tab.root.id);
2633
+ }
2634
+ /**
2635
+ * The manifest, across every tab built so far. ONE FLAT pane list — the tab is a field on each pane
2636
+ * rather than a second nesting a consumer has to walk. The unique handle is the pane `id`; `label` is
2637
+ * a name two panes may share, and the tab is reported by INDEX rather than by either.
2638
+ */
2639
+ function report(ctx, tabs) {
2640
+ return {
2641
+ template: ctx.name,
2642
+ cwd: ctx.cwd,
2643
+ workspace: ctx.workspace,
2644
+ panes: tabs.flatMap((tab) => tab.ordered.filter((pane) => tab.paneOf.has(pane)).map((pane) => ({
2645
+ label: pane.label ?? null,
2646
+ pane: tab.paneOf.get(pane),
2647
+ dir: pane === tab.rootLeaf ? tab.rootDir : resolveDir(ctx.cwd, pane.dir),
2648
+ command: pane.command ?? null,
2649
+ tab: tab.index
2650
+ })))
2651
+ };
2652
+ }
2653
+ /**
2654
+ * Every command, last — and across every tab, in template order, tab by tab. Only reachable once all
2655
+ * geometry is built: a split lands mid-render if it targets a pane already running an interactive
2656
+ * agent, and that reason does not weaken because the pane sits in another tab.
2657
+ */
2658
+ function submitCommands(tabs, ctx) {
2659
+ for (const tab of tabs) {
2660
+ const rootFallback = tab.rootEnvHonored ? void 0 : envFallback(tab.rootLeaf.env, tab.rootLeaf.command);
2661
+ if (rootFallback?.kind === "dropped") process.stderr.write(`the template's root pane "${tab.rootLeaf.label ?? "(unlabeled)"}" has env (${rootFallback.variables.join(", ")}) but no command to carry it — this backend cannot set env on the region it opens
2662
+ `);
2663
+ for (const pane of tab.ordered) {
2664
+ const command = pane === tab.rootLeaf && rootFallback?.kind === "carried" ? rootFallback.command : pane.command;
2665
+ if (!command) continue;
2666
+ ctx.adapter.submit(ctx.exec, { id: tab.paneOf.get(pane) }, command);
2667
+ }
2668
+ }
2669
+ }
2670
+ //#endregion
2671
+ //#region src/template-store.ts
2672
+ const realTemplateStore = {
2673
+ list(dir) {
2674
+ try {
2675
+ return readdirSync(dir).filter((file) => file.endsWith(".json")).map((file) => basename(file, ".json")).sort();
2676
+ } catch {
2677
+ return [];
2678
+ }
2679
+ },
2680
+ read(path) {
2681
+ try {
2682
+ return readFileSync(path, "utf8");
2683
+ } catch {
2684
+ return null;
2685
+ }
2686
+ },
2687
+ dirExists(path) {
2688
+ return existsSync(path);
2689
+ },
2690
+ write(path, contents) {
2691
+ mkdirSync(dirname(path), { recursive: true });
2692
+ writeFileSync(path, contents, "utf8");
2693
+ }
2694
+ };
2695
+ /**
2696
+ * The two searched directories.
2697
+ *
2698
+ * The repo location resolves through `resolvePrimaryRoot`, NOT `./.cyber-mux` relative to the
2699
+ * caller's cwd, and that is load-bearing rather than incidental: cyber-mux is used across many
2700
+ * worktrees of one project, and a worktree branched from a commit that predates a template would
2701
+ * otherwise silently see a stale template, or none at all. Resolving through the primary checkout
2702
+ * gives one canonical answer from every worktree.
2703
+ */
2704
+ function templateDirs(exec, env) {
2705
+ const configHome = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
2706
+ return {
2707
+ repo: join(resolvePrimaryRoot(exec), ".cyber-mux", "templates"),
2708
+ user: join(configHome, "cyber-mux", "templates")
2709
+ };
2710
+ }
2711
+ /**
2712
+ * Resolve a template to its bytes: `--file` (explicit), then the repo, then the user.
2713
+ *
2714
+ * **Repo beats user, deliberately.** A project that ships a template is making a statement about how
2715
+ * the project is worked on, and a personal template of the same name should not silently shadow it —
2716
+ * so `template list` reports each name's source and marks the user template a repo one shadows.
2717
+ *
2718
+ * Throws when nothing resolves, naming BOTH directories searched — a name that resolves nowhere is a
2719
+ * typo, and the answer to a typo is where it looked.
2720
+ */
2721
+ function resolveTemplate$1(opts) {
2722
+ if (opts.file) {
2723
+ const raw = opts.store.read(opts.file);
2724
+ if (raw === null) throw new Error(`cannot read template: ${opts.file}`);
2725
+ return {
2726
+ stem: basename(opts.file, ".json"),
2727
+ path: opts.file,
2728
+ source: "file",
2729
+ raw
2730
+ };
2731
+ }
2732
+ const name = opts.name ?? "";
2733
+ assertTemplateName(name);
2734
+ const dirs = templateDirs(opts.exec, opts.env);
2735
+ for (const source of ["repo", "user"]) {
2736
+ const path = join(dirs[source], `${name}.json`);
2737
+ const raw = opts.store.read(path);
2738
+ if (raw !== null) return {
2739
+ stem: name,
2740
+ path,
2741
+ source,
2742
+ raw
2743
+ };
2744
+ }
2745
+ throw new Error(`template "${name}" not found — searched ${dirs.repo} and ${dirs.user}`);
2746
+ }
2747
+ function assertTemplateName(name) {
2748
+ if (!isValidTemplateName(name)) throw new Error(`invalid template name "${name}" — a name must match [a-z0-9][a-z0-9-]* and be a plain filename stem`);
2749
+ }
2750
+ /**
2751
+ * Every template resolvable from here, repo first.
2752
+ *
2753
+ * A shadowed user template is REPORTED rather than omitted: the whole reason repo wins is that a
2754
+ * personal template should not silently displace the project's, and "silently" cuts both ways — a
2755
+ * user whose `pool-4` stopped being used deserves to be told why, not left to wonder.
2756
+ */
2757
+ function listTemplates(store, dirs) {
2758
+ const repo = store.list(dirs.repo);
2759
+ const repoNames = new Set(repo);
2760
+ return [...repo.map((name) => ({
2761
+ name,
2762
+ source: "repo",
2763
+ path: join(dirs.repo, `${name}.json`),
2764
+ shadowed: false
2765
+ })), ...store.list(dirs.user).map((name) => ({
2766
+ name,
2767
+ source: "user",
2768
+ path: join(dirs.user, `${name}.json`),
2769
+ shadowed: repoNames.has(name)
2770
+ }))];
2771
+ }
2772
+ //#endregion
2773
+ //#region src/worktree-session.ts
2774
+ /**
2775
+ * Grouping is possible only where the backend binds AND the caller asked for a workspace — herdr's
2776
+ * `worktree create` ALWAYS opens a workspace, so it cannot serve a pane or tab placement.
2777
+ */
2778
+ function canBind(adapter, at) {
2779
+ return Boolean(adapter.worktree) && at === "workspace";
2780
+ }
2781
+ /** True only where a group was on offer and the placement is what cost us it. */
2782
+ function isDegraded(adapter, at) {
2783
+ return Boolean(adapter.worktree) && !canBind(adapter, at);
2784
+ }
2785
+ /**
2786
+ * Create a worktree and open it.
2787
+ *
2788
+ * Routes through the backend's own primitive when it can bind (grouped), and otherwise falls back to
2789
+ * `git worktree add` plus a plain `open()`. The fallback is a complete, useful outcome — a worktree
2790
+ * open in a split pane — just not a grouped one, so it is REPORTED (`degraded`), never refused.
2791
+ * Refusing would make identical flags succeed on tmux and fail on herdr, which is exactly the
2792
+ * backend leak this seam exists to prevent.
2793
+ */
2794
+ function addAndOpenWorktree(exec, adapter, opts) {
2795
+ if (canBind(adapter, opts.at)) return {
2796
+ ...adapter.worktree.createInWorkspace(exec, {
2797
+ primaryRoot: opts.primaryRoot,
2798
+ branch: opts.branch,
2799
+ path: opts.path,
2800
+ base: opts.base,
2801
+ launch: opts.launch,
2802
+ env: opts.env,
2803
+ label: opts.label
2804
+ }),
2805
+ degraded: false,
2806
+ envHonored: !opts.env
2807
+ };
2808
+ const worktree = gitWorktreeAdapter.add(exec, {
2809
+ primaryRoot: opts.primaryRoot,
2810
+ path: opts.path,
2811
+ branch: opts.branch,
2812
+ base: opts.base
2813
+ });
2814
+ return {
2815
+ worktree,
2816
+ target: adapter.open(exec, {
2817
+ cwd: worktree.root,
2818
+ launch: opts.launch,
2819
+ env: opts.env,
2820
+ at: opts.at,
2821
+ label: opts.label,
2822
+ from: opts.from
2823
+ }),
2824
+ degraded: isDegraded(adapter, opts.at),
2825
+ envHonored: true
2826
+ };
2827
+ }
2828
+ /**
2829
+ * Open an EXISTING worktree — the remedy that groups one a bare `worktree add` created earlier, so
2830
+ * "add now, group later" is a real story rather than a dead end.
2831
+ */
2832
+ function openExistingWorktree(exec, adapter, opts) {
2833
+ const at = opts.at ?? "workspace";
2834
+ if (canBind(adapter, at)) return {
2835
+ ...adapter.worktree.openInWorkspace(exec, {
2836
+ primaryRoot: opts.primaryRoot,
2837
+ path: opts.path,
2838
+ launch: opts.launch,
2839
+ env: opts.env,
2840
+ label: opts.label
2841
+ }),
2842
+ degraded: false,
2843
+ envHonored: !opts.env
2844
+ };
2845
+ const root = normalizeWorktreePath(opts.path);
2846
+ const target = adapter.open(exec, {
2847
+ cwd: root,
2848
+ launch: opts.launch,
2849
+ env: opts.env,
2850
+ at,
2851
+ label: opts.label,
2852
+ from: opts.from
2853
+ });
2854
+ return {
2855
+ worktree: {
2856
+ root,
2857
+ branch: listWorktreesFromGit(exec, opts.primaryRoot).find((entry) => entry.root === root)?.branch ?? ""
2858
+ },
2859
+ target,
2860
+ degraded: isDegraded(adapter, at),
2861
+ envHonored: true
2862
+ };
2863
+ }
2864
+ /**
2865
+ * Every worktree of the repo, with the workspace each is open in.
2866
+ *
2867
+ * The facts come from git on EVERY backend and the backend contributes only the binding, joined by
2868
+ * normalized path. A backend that also enumerates worktrees is merely re-reading git, so letting it
2869
+ * answer would let two backends report a different branch for the same worktree — this is
2870
+ * structurally incapable of that.
2871
+ */
2872
+ function listWorktrees(exec, adapter, opts) {
2873
+ const bindings = adapter?.worktree?.bindings(exec, { primaryRoot: opts.primaryRoot });
2874
+ return listWorktreesFromGit(exec, opts.primaryRoot).map((entry) => {
2875
+ const workspace = bindings?.get(entry.root);
2876
+ return workspace ? {
2877
+ ...entry,
2878
+ workspace
2879
+ } : entry;
2880
+ });
2881
+ }
2882
+ /**
2883
+ * Remove a worktree, with identical gates on every backend: refuse the primary checkout (absolute),
2884
+ * tolerate one already gone from disk, refuse uncommitted changes unless `force`.
2885
+ *
2886
+ * Removal is never handed to the backend — only the binding's release is. See
2887
+ * `WorktreeWorkspaceCapability` for why, and `removeWorktreeSafely` for why the ordering of that
2888
+ * release is a specified property rather than an incidental one.
2889
+ */
2890
+ function removeWorktree(exec, adapter, path, opts) {
2891
+ const capability = adapter?.worktree;
2892
+ const workspace = capability?.bindings(exec, { primaryRoot: opts.primaryRoot }).get(normalizeWorktreePath(path));
2893
+ removeWorktreeSafely(exec, path, {
2894
+ primaryRoot: opts.primaryRoot,
2895
+ force: opts.force,
2896
+ releaseBinding: capability && workspace ? () => capability.releaseWorkspace(exec, workspace) : void 0
2897
+ });
2898
+ }
2899
+ //#endregion
2900
+ //#region src/cli.ts
2901
+ const REAL_DEPS = {
2902
+ env: process.env,
2903
+ exec: realExec,
2904
+ store: realTemplateStore
2905
+ };
2906
+ /**
2907
+ * Resolve the adapter for the multiplexer this process is inside, failing with a coded `no-mux` error
2908
+ * when there is none. The underlying throw is TRANSLATED, never forwarded — `selectSessionAdapter`
2909
+ * names `$TMUX`/`$HERDR_ENV`, which is backend plumbing an agent driving cyber-mux cannot act on; the
2910
+ * help names how to get a backend through this CLI instead.
2911
+ */
2912
+ function adapter(deps) {
2913
+ try {
2914
+ return selectSessionAdapter(deps.env, deps.exec);
2915
+ } catch {
2916
+ throw noMux();
2917
+ }
2918
+ }
2919
+ /** No multiplexer around this process — an operation failure (exit 1), not a usage error. */
2920
+ function noMux() {
2921
+ return new CliError("no-mux", "no multiplexer detected around this process", "run cyber-mux inside tmux or herdr, or set CYBER_MUX to name one", 1);
2922
+ }
2923
+ /** A locator that resolved to no live pane — a pane verb's not-found. Exit 1: a real operation
2924
+ * failure, distinct from a malformed argument. */
2925
+ function paneNotFound(locator) {
2926
+ return new CliError("pane-not-found", `pane "${locator}" matched no live pane`, "list the live panes with: cyber-mux list", 1);
2927
+ }
2928
+ /** A malformed template name — a usage error (exit 2): the fix is a different name, nothing was
2929
+ * attempted. The same family a missing required argument is in. */
2930
+ function invalidTemplateName(name) {
2931
+ return new CliError("invalid-template-name", `invalid template name "${name}" — a name must match [a-z0-9][a-z0-9-]* and be a plain filename stem`, "use a lowercase stem like pool-4", 2);
2932
+ }
2933
+ /**
2934
+ * A git/worktree operation that refused or failed — exit 1. A coded surface already on its way out (a
2935
+ * `no-mux`, a resolved-template error, an apply failure) passes through untouched rather than being
2936
+ * flattened into this generic one. A `WorktreeGitError` is this CLI's own worktree text (a refusal
2937
+ * naming `--force`, a primary-checkout guard), which is kept because the frozen worktree refusals are
2938
+ * asserted on it. Anything else reaching here comes from the session adapter opening/binding the
2939
+ * worktree's pane (`session.tmux.ts`/`session.herdr.ts`) and embeds the backend's own name plus its raw
2940
+ * stderr (`withReason`, `exec.lastError`) — AXI #6 forbids leaking a dependency's name or text, so that
2941
+ * detail is not load-bearing for the agent and goes to stderr as a diagnostic only; stdout carries this
2942
+ * CLI's own coded, translated error.
2943
+ */
2944
+ function reportWorktreeFailure(err) {
2945
+ if (err instanceof CliError) reportError(err);
2946
+ if (err instanceof WorktreeGitError) reportError(new CliError("worktree-failed", err.message, "check the worktree path and its state, then re-run", 1));
2947
+ if (err instanceof Error && err.message) process.stderr.write(`${err.message}\n`);
2948
+ reportError(new CliError("worktree-failed", "the worktree operation failed", "check the worktree path and its state, then re-run", 1));
2949
+ }
2950
+ /**
2951
+ * Resolve a locator — a pane id or a human label — to the one pane it names.
2952
+ *
2953
+ * **Id first, then name.** An id can never be made to mean something else by a person renaming an
2954
+ * unrelated pane, so every caller that passes ids today keeps working no matter what anyone labels.
2955
+ * That also makes ambiguity a fuzzy-tier condition only: an id hit and a label hit are not peers, so
2956
+ * they are not candidates to choose between — the same ladder git, Docker and tmux resolve targets by.
2957
+ *
2958
+ * **An id is recognized by EXISTENCE, never by syntax.** The question asked is "does a live pane carry
2959
+ * this id?", not "does this string look like an id?". Docker sniffs shape (`sg-` → an id) and it is
2960
+ * the cheaper rule, refused here: encoding a backend's id format in the CLI is exactly the backend
2961
+ * leak this seam exists to prevent, and every new backend would owe a new syntax rule. It is also
2962
+ * wrong on a real case — `%9` is id-SHAPED, but if no pane carries it as an id and one carries it as a
2963
+ * label, a sniffer reports a missing pane while the live list finds the label.
2964
+ *
2965
+ * One `listPanes` read answers both halves, so name support costs the id path a single query and no
2966
+ * behavior. The SEAM is untouched: adapters keep receiving concrete ids, and never learn that a name
2967
+ * was ever involved.
2968
+ *
2969
+ * A locator matching NOTHING resolves to itself, deliberately — it is handed to the backend as an id
2970
+ * and takes the verb's existing not-found path (exit 1). Failing here instead would make every verb's
2971
+ * "no such pane" message this function's to write.
2972
+ */
2973
+ function resolveTarget(deps, a, locator) {
2974
+ let panes;
2975
+ try {
2976
+ panes = a.listPanes(deps.exec);
2977
+ } catch {
2978
+ return { id: locator };
2979
+ }
2980
+ if (panes.some((p) => p.id === locator)) return { id: locator };
2981
+ const named = panes.filter((p) => p.label === locator);
2982
+ if (named.length === 1) return { id: named[0].id };
2983
+ if (named.length > 1) throw new AmbiguousPaneError(locator, named.map((p) => ({
2984
+ id: p.id,
2985
+ label: p.label ?? null,
2986
+ cwd: p.cwd ?? null
2987
+ })));
2988
+ return { id: locator };
2989
+ }
2990
+ /**
2991
+ * Wrap a verb's action so ANY coded failure reports itself on stdout and exits — the one place that
2992
+ * turns a `CliError` (an ambiguity, a `no-mux`, a `pane-not-found`, a template refusal) into output, for
2993
+ * every verb.
2994
+ *
2995
+ * Here rather than deeper so the report is the OUTERMOST thing a verb does: by the time it runs, every
2996
+ * inner catch-all has already had its chance to rethrow, and nothing can convert an exit-2 usage error
2997
+ * into an exit-1 generic failure behind its back. A non-`CliError` is a bug, not a surface — it is
2998
+ * rethrown to the top-level handler rather than dressed up as a coded failure.
2999
+ */
3000
+ function guarded(action) {
3001
+ return (...args) => {
3002
+ try {
3003
+ action(...args);
3004
+ } catch (err) {
3005
+ if (err instanceof CliError) reportError(err);
3006
+ throw err;
3007
+ }
3008
+ };
3009
+ }
3010
+ /**
3011
+ * Run a pane verb's body, translating a backend throw into a `pane-not-found` on the way out.
3012
+ *
3013
+ * `resolveTarget` hands an unmatched locator to the backend as an id, so a bad target surfaces as the
3014
+ * backend's OWN diagnostic — which must never reach the caller: an agent handed a tmux/herdr error
3015
+ * cannot act on it through cyber-mux. A `CliError` already on its way out (a `no-mux` from `adapter`,
3016
+ * an ambiguity from `resolveTarget`) is a coded surface and passes through untouched; anything else is
3017
+ * the multiplexer's raw failure and becomes this CLI's own code and help instead.
3018
+ */
3019
+ function paneVerb(locator, body) {
3020
+ try {
3021
+ body();
3022
+ } catch (err) {
3023
+ if (err instanceof CliError) throw err;
3024
+ throw paneNotFound(locator);
3025
+ }
3026
+ }
3027
+ /**
3028
+ * The backend when there is one, `undefined` when there is not — unlike `adapter`, which fails. For
3029
+ * verbs whose subject is git (`worktree list`/`remove`): a multiplexer can only ever add to the
3030
+ * answer, so its absence must not deny one.
3031
+ */
3032
+ function optionalAdapter(deps) {
3033
+ try {
3034
+ return selectSessionAdapter(deps.env, deps.exec);
3035
+ } catch {
3036
+ return;
3037
+ }
3038
+ }
3039
+ /**
3040
+ * One shape for every verb that opens a worktree. `printFields` drops nullish entries, so a bare
3041
+ * `worktree add` — which opens nothing — prints exactly what it always did.
3042
+ *
3043
+ * When the chosen placement cost the workspace grouping, the backend could have grouped this worktree
3044
+ * and did not — worth saying out loud. Per axi/'s #9 that next move rides in the payload on STDOUT as
3045
+ * a `help[N]:` block, not on stderr the agent never reads; `workspace: null` is the machine-readable
3046
+ * half of the same report. `regroupCommand` is the caller's own verb re-stated with `--at workspace`,
3047
+ * so the flag that would have grouped it is named as a concrete command. Emitted only when a grouping
3048
+ * was actually lost (#9's omit-when-self-contained rule), so `help` never rides along otherwise.
3049
+ */
3050
+ function reportOpenedWorktree(opened, regroupCommand) {
3051
+ const help = opened.degraded ? [{
3052
+ message: "opened ungrouped — pass --at workspace to group it with the repo",
3053
+ command: regroupCommand
3054
+ }] : [];
3055
+ output({
3056
+ root: opened.worktree.root,
3057
+ branch: opened.worktree.branch,
3058
+ pane: opened.target.id,
3059
+ workspace: opened.workspace ?? null,
3060
+ ...help.length ? { help } : {}
3061
+ }, () => {
3062
+ printFields({
3063
+ root: opened.worktree.root,
3064
+ branch: opened.worktree.branch,
3065
+ pane: opened.target.id,
3066
+ workspace: opened.workspace
3067
+ });
3068
+ printHelp(help);
3069
+ });
3070
+ }
3071
+ /**
3072
+ * `--template`, the exact sibling of `--launch`: both answer "what runs in the space you are opening",
3073
+ * one for a single pane and one for a pool. Mutually exclusive by construction — commander rejects
3074
+ * the pair rather than picking a winner.
3075
+ */
3076
+ function templateOption() {
3077
+ return new Option("--template <name>", "Named template to build in the opened space").conflicts(["launch", "env"]);
3078
+ }
3079
+ /**
3080
+ * Resolve, parse and validate a template — the whole answer BEFORE any side effect. A typo in a
3081
+ * template name must never leave a worktree behind, and an invalid template must not either, so every
3082
+ * caller runs this before it opens or creates anything.
3083
+ */
3084
+ function resolveTemplate(deps, opts) {
3085
+ if (opts.name !== void 0 && opts.file === void 0 && !isValidTemplateName(opts.name)) throw invalidTemplateName(opts.name);
3086
+ let resolved;
3087
+ try {
3088
+ resolved = resolveTemplate$1({
3089
+ name: opts.name,
3090
+ file: opts.file,
3091
+ store: deps.store,
3092
+ exec: deps.exec,
3093
+ env: deps.env
3094
+ });
3095
+ } catch (err) {
3096
+ throw new CliError("template-not-found", err instanceof Error ? err.message : String(err), "list the templates resolvable from here with: cyber-mux template list", 1);
3097
+ }
3098
+ let parsed;
3099
+ try {
3100
+ parsed = parseTemplate(resolved.raw);
3101
+ } catch (err) {
3102
+ throw new CliError("invalid-template", `${resolved.path}: ${err instanceof Error ? err.message : String(err)}`, "fix the template JSON, then re-run", 1);
3103
+ }
3104
+ const errors = validateTemplate(parsed, resolved.stem);
3105
+ if (errors.length > 0) throw new CliError("invalid-template", errors.join("\n"), "fix the fields named above, then re-run", 1);
3106
+ return {
3107
+ ...resolved,
3108
+ template: parsed
3109
+ };
3110
+ }
3111
+ /** The apply manifest — the handoff. `printFields`/`printTable` for humans, the raw object for json. */
3112
+ function reportManifest(manifest, extra = {}) {
3113
+ output({
3114
+ ...extra,
3115
+ ...manifest
3116
+ }, () => {
3117
+ printFields({
3118
+ ...extra,
3119
+ template: manifest.template,
3120
+ cwd: manifest.cwd,
3121
+ workspace: manifest.workspace
3122
+ });
3123
+ printTable(manifest.panes, [
3124
+ {
3125
+ label: "label",
3126
+ get: (p) => p.label ?? ""
3127
+ },
3128
+ {
3129
+ label: "pane",
3130
+ get: (p) => p.pane
3131
+ },
3132
+ {
3133
+ label: "dir",
3134
+ get: (p) => p.dir
3135
+ },
3136
+ {
3137
+ label: "command",
3138
+ get: (p) => p.command ?? ""
3139
+ }
3140
+ ]);
3141
+ });
3142
+ }
3143
+ /**
3144
+ * A walk that threw reports what it BUILT and exits 1, killing nothing. Rolling back would mean
3145
+ * killing panes, and a kill is not obviously safer than a half-built template the caller can see and
3146
+ * finish.
3147
+ */
3148
+ function reportApplyFailure(err, extra = {}) {
3149
+ if (err instanceof TemplateApplyError) {
3150
+ reportManifest(err.manifest, extra);
3151
+ process.stderr.write(`${err.message}\n`);
3152
+ process.exit(1);
3153
+ }
3154
+ throw new CliError("template-apply-failed", err instanceof Error ? err.message : String(err), "check the template and the target directory, then re-run", 1);
3155
+ }
3156
+ function templateListCommand(deps) {
3157
+ return new Command("list").description("Every template resolvable from here, with its source and pane count").addOption(FORMAT_OPTION).action(guarded(() => {
3158
+ const dirs = templateDirs(deps.exec, deps.env);
3159
+ const templates = listTemplates(deps.store, dirs).map((entry) => {
3160
+ let panes = 0;
3161
+ try {
3162
+ const raw = deps.store.read(entry.path);
3163
+ if (raw) panes = collectPanes(resolveTree(parseTemplate(raw))).length;
3164
+ } catch {
3165
+ panes = 0;
3166
+ }
3167
+ return {
3168
+ ...entry,
3169
+ panes
3170
+ };
3171
+ });
3172
+ output({ templates }, () => printTable(templates, [
3173
+ {
3174
+ label: "name",
3175
+ get: (l) => l.name
3176
+ },
3177
+ {
3178
+ label: "source",
3179
+ get: (l) => l.source
3180
+ },
3181
+ {
3182
+ label: "panes",
3183
+ get: (l) => String(l.panes)
3184
+ },
3185
+ {
3186
+ label: "shadowed",
3187
+ get: (l) => l.shadowed ? "yes" : ""
3188
+ }
3189
+ ]));
3190
+ }));
3191
+ }
3192
+ function templateShowCommand(deps) {
3193
+ return new Command("show").description("Print a resolved template as JSON").argument("[name]", "Template name").option("--file <path>", "Read this path instead, skipping resolution entirely").option("--desugar", "Print the canonical tree panes/arrange expands to — exactly what apply builds").action(guarded((name, opts) => {
3194
+ if (!name && !opts.file) throw new CliError("missing-argument", "template show needs a template name or --file <path>", "pass a template name, or --file <path>", 2);
3195
+ const { template } = resolveTemplate(deps, {
3196
+ name,
3197
+ file: opts.file
3198
+ });
3199
+ console.log(JSON.stringify(opts.desugar ? resolveTree(template) : template, null, 2));
3200
+ }));
3201
+ }
3202
+ function templateValidateCommand(deps) {
3203
+ return new Command("validate").description("Validate a template — exit 0 valid, 1 invalid, every error at once with a JSON path").argument("[name]", "Template name").option("--file <path>", "Validate this path instead, skipping resolution entirely").action(guarded((name, opts) => {
3204
+ if (!name && !opts.file) throw new CliError("missing-argument", "template validate needs a template name or --file <path>", "pass a template name, or --file <path>", 2);
3205
+ resolveTemplate(deps, {
3206
+ name,
3207
+ file: opts.file
3208
+ });
3209
+ }));
3210
+ }
3211
+ /**
3212
+ * `save` is the one verb here that reads a multiplexer rather than a file, and the only one that
3213
+ * WRITES: it captures a live region into a named template, so a pool built by hand once can be named
3214
+ * rather than hand-written. That is the schema's one real authoring cost — a 4+ pane grid needs
3215
+ * nested `split` nodes nobody wants to type.
3216
+ *
3217
+ * **What it saves is a draft, and the file says so.** A capture recovers geometry, labels and dirs;
3218
+ * it can never recover commands, because no multiplexer reports the command a pane was launched with
3219
+ * (`template-capture.ts` has the why). A saved template therefore lands with no `command` on any pane
3220
+ * and is immediately listed by `template list` alongside finished ones, so the draft has to announce
3221
+ * itself IN the file — hence the `description` default. Saying it only on stderr would put the
3222
+ * warning everywhere except where the reader is.
3223
+ *
3224
+ * `--to` defaults to `repo`, matching resolution's own precedence: a template is a statement about
3225
+ * how the PROJECT is worked on, and that is the copy worth having by default.
3226
+ *
3227
+ * **`save`'s subject is a REGION and stays one.** `--workspace` widens it to every tab of the
3228
+ * workspace the caller's region sits in — one captured tab per live tab, each with its own derived
3229
+ * tree, the exact inverse of the tabs walk. It is opt-in rather than the default because widening the
3230
+ * default silently would rewrite what `save` has always meant for every caller already relying on it.
3231
+ * The bare form does not stay quiet about the narrowing, though: capturing one tab of three notes on
3232
+ * stderr what it left out, rather than letting a caller believe a 3-tab workspace round-trips from a
3233
+ * 1-tab template.
3234
+ */
3235
+ function templateSaveCommand(deps) {
3236
+ return new Command("save").description("Capture the live region around a pane into a named template").argument("<name>", "Name for the captured template").option("--from <pane>", "Pane whose region to capture; defaults to this process's own pane").option("--workspace", "Capture every tab of the caller's workspace, as a tabs template").option("--description <text>", "Description to record in the template").addOption(new Option("--to <source>", "Which templates directory to write to").choices(["repo", "user"]).default("repo")).option("--force", "Overwrite an existing template of this name").addOption(FORMAT_OPTION).addHelpText("after", "\nA capture recovers geometry, labels and dirs — NOT commands: no multiplexer can report the\ncommand a pane was launched with, so every pane is saved without one. Fill them in before\nthe template is worth applying.").action(guarded((name, opts) => {
3237
+ if (!isValidTemplateName(name)) throw invalidTemplateName(name);
3238
+ try {
3239
+ const path = join(templateDirs(deps.exec, deps.env)[opts.to], `${name}.json`);
3240
+ if (!opts.force && deps.store.read(path) !== null) throw new CliError("template-exists", `template "${name}" already exists at ${path} — pass --force to overwrite it`, "re-run with --force to replace it", 1);
3241
+ const a = adapter(deps);
3242
+ const describeRegion = a.describeRegion;
3243
+ if (!opts.workspace && !describeRegion) throw new CliError("backend-unsupported", `${a.name} cannot report a region's geometry — template save needs a backend that can`, "run template save on a backend that reports geometry (tmux or herdr)", 1);
3244
+ const describeWorkspace = a.describeWorkspace;
3245
+ if (opts.workspace && !describeWorkspace) throw new CliError("backend-unsupported", `${a.name} cannot enumerate a workspace's tabs — template save --workspace needs a backend that can`, "run template save --workspace on a backend that enumerates tabs (tmux or herdr)", 1);
3246
+ const target = opts.from ? resolveTarget(deps, a, opts.from) : callerPane(a, deps.env);
3247
+ if (!target) throw new CliError("missing-pane", "template save needs a pane to capture the region around — pass --from <pane>, or run it inside one", "pass --from <pane>, or run template save inside a pane", 2);
3248
+ const captureOpts = {
3249
+ name,
3250
+ description: opts.description ?? CAPTURED_DESCRIPTION
3251
+ };
3252
+ const { template, warnings } = opts.workspace ? captureWorkspaceTemplate(describeWorkspace(deps.exec, target), captureOpts) : captureTemplate(describeRegion(deps.exec, target), captureOpts);
3253
+ deps.store.write(path, `${JSON.stringify(template, null, 2)}\n`);
3254
+ for (const warning of warnings) process.stderr.write(`${warning}\n`);
3255
+ const entry = opts.workspace ? null : noteTabsLeftOut(deps, a, target, name);
3256
+ const help = entry ? [entry] : [];
3257
+ output({
3258
+ path,
3259
+ ...help.length ? { help } : {}
3260
+ }, () => {
3261
+ printFields({ path });
3262
+ printHelp(help);
3263
+ });
3264
+ } catch (err) {
3265
+ if (err instanceof CliError) throw err;
3266
+ throw new CliError("unsplittable-region", "this region could not be captured — it is not a tree any sequence of splits could have produced", "template save can only capture a region built by splitting", 1);
3267
+ }
3268
+ }));
3269
+ }
3270
+ /**
3271
+ * What a bare `save` left behind: a `help` entry when the caller's workspace holds tabs this capture
3272
+ * did not take, so the capture is honest about its own scope rather than letting a caller believe a
3273
+ * 3-tab workspace round-trips from a 1-tab template.
3274
+ *
3275
+ * Per axi/'s #9 this reveal rides in `save`'s stdout payload as a `help[N]:` block, not on stderr the
3276
+ * agent never reads — programmatic composition reads the path from `--format json`, not bare stdout.
3277
+ * It is a NOTE rather than a refusal: the template the caller asked for is correct and is already
3278
+ * written. Which is also why this is best-effort — a workspace read that fails or that the backend
3279
+ * cannot do at all returns `null` (no entry), costing the caller a courtesy, not their capture. An
3280
+ * untagged window on a backend with no workspace tier reports one tab and yields nothing, which is
3281
+ * right — a window nobody grouped is a workspace of one, and nothing was left out. The `command`
3282
+ * re-states the caller's own `name` with `--workspace`, the flag that captures every tab.
3283
+ */
3284
+ function noteTabsLeftOut(deps, adapter, target, name) {
3285
+ if (!adapter.describeWorkspace) return null;
3286
+ let tabs;
3287
+ try {
3288
+ tabs = adapter.describeWorkspace(deps.exec, target).length;
3289
+ } catch {
3290
+ return null;
3291
+ }
3292
+ if (tabs <= 1) return null;
3293
+ return {
3294
+ message: `this pane's workspace holds ${tabs} tabs — only the caller's own region was captured. Pass --workspace to capture every tab of it`,
3295
+ command: `cyber-mux template save ${name} --workspace`
3296
+ };
3297
+ }
3298
+ /**
3299
+ * The default `description` on a captured template — the draft warning, written where the reader
3300
+ * actually is. A saved capture is listed by `template list` next to finished templates and shows up in
3301
+ * `template show`, so a note that only ever reached the terminal that ran `save` would be gone by the
3302
+ * time anyone reads the file. Overridden by `--description`, since an author who names the template's
3303
+ * purpose has said something more useful than this.
3304
+ */
3305
+ const CAPTURED_DESCRIPTION = "Captured from a live region — geometry only; add a command to each pane.";
3306
+ /**
3307
+ * The `template` group manages templates — there is deliberately no `template apply`. Applying is what
3308
+ * `open` and `worktree add` already do, told to build N panes instead of one, so it is `--template` on
3309
+ * those verbs.
3310
+ *
3311
+ * `list` / `show` / `validate` take a FILE as their subject and touch no multiplexer. `save` is the
3312
+ * exception in both respects — it reads a live region and writes a file — and it belongs here anyway:
3313
+ * it AUTHORS a template, which is what this group is for.
3314
+ */
3315
+ function templateCommand(deps) {
3316
+ const cmd = new Command("template").description("Manage named templates (apply one with open/worktree --template)");
3317
+ cmd.addCommand(templateListCommand(deps));
3318
+ cmd.addCommand(templateShowCommand(deps));
3319
+ cmd.addCommand(templateValidateCommand(deps));
3320
+ cmd.addCommand(templateSaveCommand(deps));
3321
+ return cmd;
3322
+ }
3323
+ function doctorCommand(deps) {
3324
+ return new Command("doctor").description("Probe the multiplexer, self pane, and backend; print fast-path pins").addOption(FORMAT_OPTION).action(() => {
3325
+ const probe = probeMultiplexer(deps.exec, deps.env);
3326
+ const self = currentPane(deps.env);
3327
+ let backend = "none";
3328
+ try {
3329
+ backend = selectSessionAdapter(deps.env, deps.exec).name;
3330
+ } catch {}
3331
+ const data = {
3332
+ mux: probe.mux,
3333
+ via: probe.via,
3334
+ pane: self?.pane ?? probe.pane ?? null,
3335
+ backend
3336
+ };
3337
+ output(data, () => {
3338
+ printFields({
3339
+ multiplexer: data.mux,
3340
+ "detected via": data.via,
3341
+ pane: data.pane ?? "(none)",
3342
+ backend: data.backend
3343
+ });
3344
+ if (self) {
3345
+ console.log("");
3346
+ console.log("Pin the fast-path to skip detection:");
3347
+ console.log(` export CYBER_MUX=${self.mux} CYBER_MUX_PANE=${self.pane}`);
3348
+ }
3349
+ });
3350
+ });
3351
+ }
3352
+ function modeCommand(deps) {
3353
+ return new Command("mode").description("Report the detected session backend (tmux / herdr / none)").addOption(FORMAT_OPTION).action(() => {
3354
+ let name = "none";
3355
+ try {
3356
+ name = selectSessionAdapter(deps.env, deps.exec).name;
3357
+ } catch {}
3358
+ output({ backend: name }, () => console.log(name));
3359
+ });
3360
+ }
3361
+ function openCommand(deps) {
3362
+ return new Command("open").description("Open a new pane/tab/workspace, optionally launching a command in it").option("--launch <command>", "Command line to run in the new pane").addOption(templateOption()).option("--cwd <path>", "Working directory for the new pane", process.cwd()).addOption(AT_OPTION).addOption(ENV_OPTION).addOption(LABEL_OPTION).addOption(FORMAT_OPTION).action(guarded((opts) => {
3363
+ if (opts.template) {
3364
+ const { template } = resolveTemplate(deps, { name: opts.template });
3365
+ const a = adapter(deps);
3366
+ try {
3367
+ reportManifest(openTemplate(deps.exec, a, template, {
3368
+ cwd: opts.cwd,
3369
+ at: opts.at ?? "workspace",
3370
+ label: opts.label ?? template.name,
3371
+ dirExists: deps.store.dirExists,
3372
+ from: callerPane(a, deps.env)
3373
+ }));
3374
+ } catch (err) {
3375
+ reportApplyFailure(err);
3376
+ }
3377
+ return;
3378
+ }
3379
+ const a = adapter(deps);
3380
+ const t = a.open(deps.exec, {
3381
+ cwd: opts.cwd,
3382
+ launch: opts.launch,
3383
+ at: opts.at,
3384
+ env: opts.env,
3385
+ label: opts.label,
3386
+ from: callerPane(a, deps.env)
3387
+ });
3388
+ output({
3389
+ pane: t.id,
3390
+ workspace: t.workspace ?? null
3391
+ }, () => printFields({
3392
+ pane: t.id,
3393
+ workspace: t.workspace
3394
+ }));
3395
+ }));
3396
+ }
3397
+ /** The `send` group: drive a pane's input WITHOUT taking its turn. Neither subcommand presses an
3398
+ * Enter the caller did not write — supplying one is `submit`'s job. Bare `cyber-mux send` is
3399
+ * incomplete input, not a content request: it is answered with help on stdout and exit 2 (a usage
3400
+ * error — a missing required parameter; see the AXI note in `.agents/spec/axi/README.md`). */
3401
+ function sendCommand(deps) {
3402
+ const send = new Command("send").description("Drive a pane without taking its turn (text | keys)");
3403
+ send.addCommand(new Command("text").description("Type literal text into a pane, pressing no Enter (a key-named word is typed, not pressed)").argument("<pane>", "Target pane id").argument("<text>", "Literal text to type").addOption(FORMAT_OPTION).action(guarded((pane, text) => {
3404
+ paneVerb(pane, () => {
3405
+ const a = adapter(deps);
3406
+ a.sendText(deps.exec, resolveTarget(deps, a, pane), text);
3407
+ });
3408
+ })));
3409
+ send.addCommand(new Command("keys").description("Press named keys in a pane, typing nothing (Up, Enter, Escape, C-c, F1 …)").argument("<pane>", "Target pane id").argument("<keys...>", "Key names, in order — core vocabulary is portable, anything else is passed to the backend as-is").addOption(FORMAT_OPTION).action(guarded((pane, keys) => {
3410
+ paneVerb(pane, () => {
3411
+ const a = adapter(deps);
3412
+ a.sendKeys(deps.exec, resolveTarget(deps, a, pane), keys);
3413
+ });
3414
+ })));
3415
+ return send;
3416
+ }
3417
+ function submitCommand(deps) {
3418
+ return new Command("submit").description("Take a pane's turn: type the text if given, then always press Enter (no text = bare-Enter flush)").argument("<pane>", "Target pane id").argument("[text]", "Text to type before Enter; omit to flush an already-staged buffer without retyping it").addOption(FORMAT_OPTION).action(guarded((pane, text) => {
3419
+ paneVerb(pane, () => {
3420
+ const a = adapter(deps);
3421
+ a.submit(deps.exec, resolveTarget(deps, a, pane), text);
3422
+ });
3423
+ }));
3424
+ }
3425
+ function readCommand(deps) {
3426
+ return new Command("read").description("Capture a pane's output").argument("<pane>", "Target pane id").option("--lines <n>", "Trailing lines to capture", (v) => Number.parseInt(v, 10)).addOption(FORMAT_OPTION).action(guarded((pane, opts) => {
3427
+ paneVerb(pane, () => {
3428
+ const a = adapter(deps);
3429
+ const t = resolveTarget(deps, a, pane);
3430
+ const out = a.read(deps.exec, t, opts.lines != null ? { lines: opts.lines } : void 0);
3431
+ process.stdout.write(out.endsWith("\n") ? out : `${out}\n`);
3432
+ });
3433
+ }));
3434
+ }
3435
+ function focusCommand(deps) {
3436
+ return new Command("focus").description("Beam the attached client to a pane").argument("<pane>", "Target pane id").addOption(FORMAT_OPTION).action(guarded((pane) => {
3437
+ paneVerb(pane, () => {
3438
+ const a = adapter(deps);
3439
+ a.focus(deps.exec, resolveTarget(deps, a, pane));
3440
+ });
3441
+ }));
3442
+ }
3443
+ function closeCommand(deps) {
3444
+ return new Command("close").description("Close a pane").argument("<pane>", "Target pane id").addOption(FORMAT_OPTION).action(guarded((pane) => {
3445
+ paneVerb(pane, () => {
3446
+ const a = adapter(deps);
3447
+ a.teardown(deps.exec, resolveTarget(deps, a, pane));
3448
+ });
3449
+ }));
3450
+ }
3451
+ function listCommand(deps) {
3452
+ return new Command("list").description("Enumerate every live pane the current backend can see").addOption(FORMAT_OPTION).action(guarded(() => {
3453
+ const panes = adapter(deps).listPanes(deps.exec);
3454
+ output({ panes }, () => printTable(panes, [
3455
+ {
3456
+ label: "pane",
3457
+ get: (p) => p.id
3458
+ },
3459
+ {
3460
+ label: "label",
3461
+ get: (p) => p.label ?? ""
3462
+ },
3463
+ {
3464
+ label: "harness",
3465
+ get: (p) => p.harness ?? ""
3466
+ },
3467
+ {
3468
+ label: "cwd",
3469
+ get: (p) => p.cwd ?? ""
3470
+ }
3471
+ ]));
3472
+ }));
3473
+ }
3474
+ function existsCommand(deps) {
3475
+ return new Command("exists").description("Probe whether a single pane is still live (exit 0 = live, 1 = gone)").argument("<pane>", "Target pane id").addOption(FORMAT_OPTION).action(guarded((pane) => {
3476
+ const a = adapter(deps);
3477
+ const t = resolveTarget(deps, a, pane);
3478
+ const live = a.paneExists(deps.exec, t);
3479
+ output({
3480
+ pane,
3481
+ live
3482
+ }, () => console.log(live ? "live" : "gone"));
3483
+ if (!live) process.exit(1);
3484
+ }));
3485
+ }
3486
+ function worktreeAddCommand(deps) {
3487
+ return new Command("add").description("Create a git worktree, and open it when given a placement — grouped where the backend can").requiredOption("--branch <branch>", "Branch to create the worktree on").option("--path <path>", "Where to check out the worktree (default: a sibling of the primary checkout)").option("--base <ref>", "Start point for the new branch (default: the current HEAD)").option("--launch <command>", "Command to run in the opened pane; implies --at workspace").addOption(templateOption()).addOption(AT_OPTION).addOption(ENV_OPTION).addOption(LABEL_OPTION).addOption(FORMAT_OPTION).action((opts) => {
3488
+ try {
3489
+ const primaryRoot = resolvePrimaryRoot(deps.exec);
3490
+ if (opts.template) {
3491
+ const { template } = resolveTemplate(deps, { name: opts.template });
3492
+ const path = opts.path ?? resolveWorktreePath(primaryRoot, opts.branch);
3493
+ const a = adapter(deps);
3494
+ const opened = addAndOpenWorktree(deps.exec, a, {
3495
+ primaryRoot,
3496
+ branch: opts.branch,
3497
+ path,
3498
+ base: opts.base,
3499
+ env: templateRootPane(template).env,
3500
+ at: "workspace",
3501
+ label: opts.label ?? template.name,
3502
+ from: callerPane(a, deps.env)
3503
+ });
3504
+ const extra = {
3505
+ root: opened.worktree.root,
3506
+ branch: opened.worktree.branch
3507
+ };
3508
+ try {
3509
+ reportManifest(applyTemplateToRegion(deps.exec, a, template, {
3510
+ root: opened.target,
3511
+ cwd: opened.worktree.root,
3512
+ workspace: opened.workspace ?? null,
3513
+ label: opts.label ?? template.name,
3514
+ rootEnvHonored: opened.envHonored,
3515
+ dirExists: deps.store.dirExists
3516
+ }), extra);
3517
+ } catch (err) {
3518
+ reportApplyFailure(err, extra);
3519
+ }
3520
+ return;
3521
+ }
3522
+ const path = opts.path ?? resolveWorktreePath(primaryRoot, opts.branch);
3523
+ if (!opts.at && !opts.launch && !opts.env) {
3524
+ const wt = gitWorktreeAdapter.add(deps.exec, {
3525
+ primaryRoot,
3526
+ path,
3527
+ branch: opts.branch,
3528
+ base: opts.base
3529
+ });
3530
+ output({
3531
+ root: wt.root,
3532
+ branch: wt.branch,
3533
+ pane: null,
3534
+ workspace: null
3535
+ }, () => printFields({
3536
+ root: wt.root,
3537
+ branch: wt.branch
3538
+ }));
3539
+ return;
3540
+ }
3541
+ const at = opts.at ?? "workspace";
3542
+ const a = adapter(deps);
3543
+ reportOpenedWorktree(addAndOpenWorktree(deps.exec, a, {
3544
+ primaryRoot,
3545
+ branch: opts.branch,
3546
+ path,
3547
+ base: opts.base,
3548
+ launch: opts.launch,
3549
+ env: opts.env,
3550
+ at,
3551
+ label: opts.label,
3552
+ from: callerPane(a, deps.env)
3553
+ }), `cyber-mux worktree add --branch ${opts.branch} --at workspace`);
3554
+ } catch (err) {
3555
+ reportWorktreeFailure(err);
3556
+ }
3557
+ });
3558
+ }
3559
+ function worktreeOpenCommand(deps) {
3560
+ return new Command("open").description("Open an existing git worktree — groups it with the repo where the backend can bind").argument("<path>", "Worktree path to open").option("--launch <command>", "Command to run in the opened pane").addOption(AT_OPTION).addOption(ENV_OPTION).addOption(LABEL_OPTION).addOption(FORMAT_OPTION).action((path, opts) => {
3561
+ try {
3562
+ const primaryRoot = resolvePrimaryRoot(deps.exec);
3563
+ const a = adapter(deps);
3564
+ reportOpenedWorktree(openExistingWorktree(deps.exec, a, {
3565
+ primaryRoot,
3566
+ path,
3567
+ launch: opts.launch,
3568
+ env: opts.env,
3569
+ at: opts.at,
3570
+ label: opts.label,
3571
+ from: callerPane(a, deps.env)
3572
+ }), `cyber-mux worktree open ${path} --at workspace`);
3573
+ } catch (err) {
3574
+ reportWorktreeFailure(err);
3575
+ }
3576
+ });
3577
+ }
3578
+ function worktreeListCommand(deps) {
3579
+ return new Command("list").description("Every worktree of the repo, and the workspace each is open in").addOption(FORMAT_OPTION).action(() => {
3580
+ try {
3581
+ const primaryRoot = resolvePrimaryRoot(deps.exec);
3582
+ const worktrees = listWorktrees(deps.exec, optionalAdapter(deps), { primaryRoot });
3583
+ output({ worktrees }, () => printTable(worktrees, [
3584
+ {
3585
+ label: "branch",
3586
+ get: (w) => w.branch ?? "(detached)"
3587
+ },
3588
+ {
3589
+ label: "root",
3590
+ get: (w) => w.root
3591
+ },
3592
+ {
3593
+ label: "linked",
3594
+ get: (w) => String(w.linked)
3595
+ },
3596
+ {
3597
+ label: "workspace",
3598
+ get: (w) => w.workspace ?? ""
3599
+ }
3600
+ ]));
3601
+ } catch (err) {
3602
+ reportWorktreeFailure(err);
3603
+ }
3604
+ });
3605
+ }
3606
+ function worktreeRemoveCommand(deps) {
3607
+ return new Command("remove").description("Remove a git worktree — refuses the primary checkout and uncommitted changes unless --force").argument("<path>", "Worktree path to remove").option("--force", "Discard uncommitted changes in the worktree").action((path, opts) => {
3608
+ try {
3609
+ const primaryRoot = resolvePrimaryRoot(deps.exec);
3610
+ removeWorktree(deps.exec, optionalAdapter(deps), path, {
3611
+ primaryRoot,
3612
+ force: opts.force
3613
+ });
3614
+ } catch (err) {
3615
+ reportWorktreeFailure(err);
3616
+ }
3617
+ });
3618
+ }
3619
+ function worktreeCommand(deps) {
3620
+ const cmd = new Command("worktree").description("Git worktree helpers for spawning/tearing down a session");
3621
+ cmd.addCommand(worktreeAddCommand(deps));
3622
+ cmd.addCommand(worktreeOpenCommand(deps));
3623
+ cmd.addCommand(worktreeListCommand(deps));
3624
+ cmd.addCommand(worktreeRemoveCommand(deps));
3625
+ return cmd;
3626
+ }
3627
+ /**
3628
+ * Translate a commander-level rejection into the SAME coded error surface every verb uses. commander's
3629
+ * own failures — a flag the command does not define, a required argument the parser never received, two
3630
+ * mutually-exclusive flags — are USAGE errors: the fix is a different invocation, not a retry, so they
3631
+ * exit 2, and they belong on stdout under a stable code exactly as an operation failure does.
3632
+ *
3633
+ * The callback is attached per command, so `command` is the SUBCOMMAND actually invoked — which is what
3634
+ * lets an unknown flag be rejected against that subcommand's own flags (`template list` does not share
3635
+ * `template save`'s), and the offending flag be named beside them so the agent self-corrects in one turn
3636
+ * rather than a second `--help` round trip.
3637
+ */
3638
+ function handleCommanderError(command, err) {
3639
+ if (err.code === "commander.helpDisplayed" || err.code === "commander.version") process.exit(err.exitCode);
3640
+ if (err.code === "commander.help") {
3641
+ process.stdout.write(command.helpInformation());
3642
+ process.exit(2);
3643
+ }
3644
+ if (err.code === "commander.unknownOption") reportError(unknownFlagError(command, err));
3645
+ if (err.code === "commander.missingArgument") reportError(missingArgumentError(command, err));
3646
+ if (err.code === "commander.conflictingOption" || err.code === "commander.excessArguments") reportError(new CliError("usage-error", usageMessage(err), "pass only one of the conflicting flags, then re-run", 2));
3647
+ throw err;
3648
+ }
3649
+ /** commander's raw message minus its own `error: ` prefix — its own CLI's text, safe to surface. */
3650
+ function usageMessage(err) {
3651
+ return (err.message ?? "").replace(/^error:\s*/, "");
3652
+ }
3653
+ /** An unknown flag, named beside the command's OWN valid flags, so the agent self-corrects in one turn. */
3654
+ function unknownFlagError(command, err) {
3655
+ const flag = err.message.match(/'([^']+)'/)?.[1] ?? "the flag";
3656
+ const valid = command.options.map((o) => o.long ?? o.short).filter((f) => Boolean(f));
3657
+ return new CliError("unknown-flag", `unknown flag ${flag} for ${command.name()}`, valid.length > 0 ? `valid flags for ${command.name()}: ${valid.join(" ")}` : `${command.name()} takes no flags`, 2);
3658
+ }
3659
+ /** A required argument the parser never received — a usage error naming the missing argument. */
3660
+ function missingArgumentError(command, err) {
3661
+ const arg = err.message.match(/'([^']+)'/)?.[1] ?? "an argument";
3662
+ return new CliError("missing-argument", `missing required argument: ${arg}`, `provide ${arg}: cyber-mux ${command.name()} <${arg}>`, 2);
3663
+ }
3664
+ /**
3665
+ * Every command in the tree gets a translating `exitOverride` — NOT inherited by subcommands, so it is
3666
+ * walked. Without it `cyber-mux send` with no subcommand would `process.exit` straight from the group
3667
+ * and kill the caller's process (in tests, the runner itself); with the plain default it would throw a
3668
+ * bare `CommanderError`. This routes commander's own rejections through the coded error surface, so a
3669
+ * missing argument or unknown flag reaches the caller as an exit-2 structured error on stdout, exactly
3670
+ * as an ambiguity or a `no-mux` does.
3671
+ */
3672
+ function exitOverrideTree(command) {
3673
+ command.exitOverride((err) => handleCommanderError(command, err));
3674
+ for (const sub of command.commands) exitOverrideTree(sub);
3675
+ return command;
3676
+ }
3677
+ /** Assembles the full command tree against the given deps (real env/exec in production, fakes in
3678
+ * tests). Every command in the tree gets `exitOverride()`, so commander throws a `CommanderError`
3679
+ * instead of calling `process.exit` directly and a rejection (an invalid `--at` choice, a missing
3680
+ * argument, a bare `send`) is catchable both here and in tests, rather than killing the test
3681
+ * runner's own process. */
3682
+ function buildProgram(cliDeps = REAL_DEPS) {
3683
+ const deps = {
3684
+ env: cliDeps.env,
3685
+ exec: cliDeps.exec,
3686
+ store: cliDeps.store ?? realTemplateStore
3687
+ };
3688
+ const program = new Command().name("cyber-mux").description("Cross-multiplexer pane control — one contract over tmux and herdr").version("0.0.0");
3689
+ program.addCommand(doctorCommand(deps));
3690
+ program.addCommand(modeCommand(deps));
3691
+ program.addCommand(openCommand(deps));
3692
+ program.addCommand(sendCommand(deps));
3693
+ program.addCommand(submitCommand(deps));
3694
+ program.addCommand(readCommand(deps));
3695
+ program.addCommand(focusCommand(deps));
3696
+ program.addCommand(closeCommand(deps));
3697
+ program.addCommand(listCommand(deps));
3698
+ program.addCommand(existsCommand(deps));
3699
+ program.addCommand(worktreeCommand(deps));
3700
+ program.addCommand(templateCommand(deps));
3701
+ return exitOverrideTree(program);
3702
+ }
3703
+ /** The real CLI entry point — called explicitly by `bin/cyber-mux.mjs`, never as an import-time
3704
+ * side effect, so importing this module (e.g. from tests) never runs the real CLI. */
3705
+ async function main() {
3706
+ try {
3707
+ await buildProgram().parseAsync(process.argv);
3708
+ } catch (err) {
3709
+ if (err instanceof CliError) reportError(err);
3710
+ if (err instanceof CommanderError) process.exit(err.exitCode);
3711
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
3712
+ process.exit(1);
3713
+ }
3714
+ }
3715
+ //#endregion
3716
+ export { buildProgram, main };