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/bin/cyber-mux.mjs +7 -0
- package/dist/cli.mjs +3716 -0
- package/package.json +58 -0
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 };
|