privateer-agent 0.12.57 → 0.13.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.
@@ -0,0 +1,376 @@
1
+ // Named terminals: the part that talks to the terminal program itself.
2
+ //
3
+ // A "terminal" here is a window (or tab, or tmux window) the user can SEE: the agent
4
+ // opens one for a dev server, a REPL or a second agent, gives it a name, and either
5
+ // of them can bring it to the front by that name later. Output is not read back:
6
+ // that is what bash and background tasks are for. These are for the person at the
7
+ // keyboard.
8
+ //
9
+ // IDENTITY IS THE TTY. Every backend can tell us which pseudo-terminal a window owns,
10
+ // and the same tty is what a process running inside it sees as its controlling
11
+ // terminal. So a Privateer started in a window we opened finds its own name by asking
12
+ // `ps` for its tty, and a window the user opened by hand can still be named and
13
+ // focused once a session inside it has registered it. Window titles would have been
14
+ // easier to match and are worthless as identity: the shell rewrites them on every
15
+ // prompt.
16
+ //
17
+ // THREE BACKENDS, ONE ON PURPOSE PER PLACE. tmux wins whenever we are inside it,
18
+ // because there the "window" the user sees is tmux's, whatever app draws it. Outside
19
+ // tmux: macOS Terminal and iTerm2 through AppleScript. Everything else (Ghostty,
20
+ // kitty, WezTerm, Linux and Windows terminals with no tmux) gets a clear "not here"
21
+ // rather than a guess: focusing a window by name on X11/Wayland/Windows needs a
22
+ // window manager contract we can't count on, and tmux runs everywhere.
23
+ //
24
+ // AppleScript runs as the terminal app controlling ITSELF (Privateer is a child of the
25
+ // window it draws in). Terminal does this without an Automation prompt (verified);
26
+ // iTerm2 is untested here and may ask once.
27
+
28
+ import { execFile } from "node:child_process";
29
+
30
+ export type TerminalApp = "tmux" | "Apple_Terminal" | "iTerm.app";
31
+
32
+ export interface OpenRequest {
33
+ name: string;
34
+ cwd: string;
35
+ /** Typed into the new shell, so it runs with the user's own PATH and rc files. */
36
+ command?: string;
37
+ /** Bring it to the front (default). False leaves focus where it was. */
38
+ focus: boolean;
39
+ }
40
+
41
+ export interface TerminalBackend {
42
+ app: TerminalApp;
43
+ /** Human name for messages. */
44
+ label: string;
45
+ /** Open a window and return its id (a tty path, or a tmux pane id). */
46
+ open(req: OpenRequest): Promise<string>;
47
+ /** Bring the window with this id to the front. False when it no longer exists. */
48
+ focus(id: string): Promise<boolean>;
49
+ /** Close it, ending what runs inside. False when it no longer exists. */
50
+ close(id: string): Promise<boolean>;
51
+ /** Ids of every window that exists right now, for pruning the registry. */
52
+ live(): Promise<Set<string>>;
53
+ /** The id of the window this process runs in, if it can tell. */
54
+ selfId(): Promise<string | undefined>;
55
+ /** Is this window the one in front, app and all? undefined when it can't tell. */
56
+ isFront(id: string): Promise<boolean | undefined>;
57
+ }
58
+
59
+ /** Run a program and hand back stdout; injectable so tests can see the exact calls. */
60
+ export type Exec = (file: string, args: string[]) => Promise<string>;
61
+
62
+ export const execProgram: Exec = (file, args) =>
63
+ new Promise((resolve, reject) => {
64
+ execFile(file, args, { timeout: 10_000, encoding: "utf8" }, (err, stdout, stderr) => {
65
+ if (err) reject(new Error(String(stderr || err.message).trim()));
66
+ else resolve(String(stdout));
67
+ });
68
+ });
69
+
70
+ /** Single-quote for POSIX sh: the one quoting that has no special characters inside. */
71
+ export function shQuote(s: string): string {
72
+ return `'${s.replace(/'/g, `'\\''`)}'`;
73
+ }
74
+
75
+ /** An AppleScript string literal. */
76
+ export function asString(s: string): string {
77
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
78
+ }
79
+
80
+ /** What gets typed into a new shell: change to the folder, then run the command if any. */
81
+ export function shellLine(cwd: string, command?: string): string {
82
+ const cd = `cd ${shQuote(cwd)}`;
83
+ return command?.trim() ? `${cd} && ${command.trim()}` : cd;
84
+ }
85
+
86
+ const TTY = /\/dev\/tty[\w.]+/g;
87
+
88
+ /** This process's controlling tty as a /dev path, from ps (the one tool both Unixes ship). */
89
+ export async function ttyOf(pid: number, exec: Exec = execProgram): Promise<string | undefined> {
90
+ try {
91
+ const out = (await exec("ps", ["-o", "tty=", "-p", String(pid)])).trim();
92
+ if (!out || out.startsWith("?")) return undefined;
93
+ return out.startsWith("/dev/") ? out : `/dev/${out}`;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Hang up everything running on a tty — what closing the window by hand does — so the
101
+ * close that follows has nothing to ask about. Without this, Terminal answers a close
102
+ * of a busy window with a "terminate running processes?" sheet, the window stays, and
103
+ * every later close of it is swallowed by that sheet. (macOS `pkill -t` matches
104
+ * nothing here, hence ps + kill.) Processes we may not signal (login) are skipped.
105
+ */
106
+ export async function hangUp(tty: string, exec: Exec = execProgram): Promise<void> {
107
+ const name = tty.replace(/^\/dev\//, "");
108
+ let pids: string[];
109
+ try {
110
+ pids = (await exec("ps", ["-t", name, "-o", "pid="])).split(/\s+/).filter((p) => /^\d+$/.test(p));
111
+ } catch {
112
+ return; // nothing on it
113
+ }
114
+ for (const pid of pids) {
115
+ if (Number(pid) === process.pid) continue;
116
+ await exec("kill", ["-HUP", pid]).catch(() => undefined);
117
+ }
118
+ }
119
+
120
+ const pause = (ms: number) => new Promise((r) => setTimeout(r, ms));
121
+
122
+ /**
123
+ * Hang up, close, and confirm the window is really gone. The window is pinned by the
124
+ * app's own id BEFORE the hang-up: once nothing runs on the tty, a lookup by tty no
125
+ * longer finds it (see findTab), and a tty is reused the moment it's free.
126
+ */
127
+ async function closeChecked(
128
+ tty: string,
129
+ exec: Exec,
130
+ label: string,
131
+ steps: { handle: () => Promise<string>; close: (handle: string) => Promise<unknown>; exists: (handle: string) => Promise<boolean> },
132
+ ): Promise<boolean> {
133
+ const handle = (await steps.handle()).trim();
134
+ if (!handle || handle === "missing") return false;
135
+ await hangUp(tty, exec);
136
+ await pause(300);
137
+ await steps.close(handle);
138
+ for (let i = 0; i < 10; i++) {
139
+ if (!(await steps.exists(handle))) return true;
140
+ await pause(200);
141
+ }
142
+ throw new Error(`${label} kept the window open — it may be asking you to confirm. Close it by hand.`);
143
+ }
144
+
145
+ // ── macOS Terminal ──────────────────────────────────────────────────────────
146
+
147
+ // Every lookup walks windows → tabs and matches on tty. `return` inside the loops
148
+ // matters: closing a window mid-iteration invalidates the list being walked.
149
+ //
150
+ // Only tabs with something still running count. A window whose shell has exited stays
151
+ // on screen ("[Process completed]") still reporting its old tty, and macOS hands that
152
+ // tty to the next window opened — so without this, the dead window and the new one
153
+ // would answer to the same id.
154
+ const findTab = (tty: string, body: string): string => `
155
+ if application "Terminal" is not running then return "missing"
156
+ tell application "Terminal"
157
+ repeat with w in windows
158
+ repeat with t in tabs of w
159
+ if tty of t is ${asString(tty)} and (count of processes of t) > 0 then
160
+ ${body}
161
+ return "ok"
162
+ end if
163
+ end repeat
164
+ end repeat
165
+ end tell
166
+ return "missing"`;
167
+
168
+ export function appleTerminal(exec: Exec = execProgram): TerminalBackend {
169
+ const osa = (script: string) => exec("osascript", ["-e", script]);
170
+ return {
171
+ app: "Apple_Terminal",
172
+ label: "Terminal",
173
+ async open(req) {
174
+ // `do script` with no target opens a new window and returns its tab.
175
+ const out = await osa(`
176
+ tell application "Terminal"
177
+ set t to do script ${asString(shellLine(req.cwd, req.command))}
178
+ set custom title of t to ${asString(req.name)}
179
+ set title displays custom title of t to true
180
+ ${req.focus ? "activate" : ""}
181
+ return tty of t
182
+ end tell`);
183
+ const tty = out.match(TTY)?.[0];
184
+ if (!tty) throw new Error(`Terminal opened a window but did not say which (${out.trim() || "no output"})`);
185
+ return tty;
186
+ },
187
+ async focus(id) {
188
+ // index 1 is "frontmost among Terminal's windows"; activate brings Terminal forward.
189
+ return (await osa(findTab(id, "set selected of t to true\n set index of w to 1\n activate"))).trim() === "ok";
190
+ },
191
+ async close(id) {
192
+ return closeChecked(id, exec, "Terminal", {
193
+ handle: () => osa(findTab(id, "return id of w")),
194
+ close: (wid) => osa(`tell application "Terminal" to close (window id ${Number(wid)})`),
195
+ exists: async (wid) =>
196
+ (await osa(`tell application "Terminal" to return exists window id ${Number(wid)}`)).trim() === "true",
197
+ });
198
+ },
199
+ async live() {
200
+ const out = await osa(`
201
+ if application "Terminal" is not running then return ""
202
+ set out to ""
203
+ tell application "Terminal"
204
+ repeat with w in windows
205
+ repeat with t in tabs of w
206
+ if (count of processes of t) > 0 then set out to out & (tty of t) & linefeed
207
+ end repeat
208
+ end repeat
209
+ end tell
210
+ return out`);
211
+ return new Set(out.match(TTY) ?? []);
212
+ },
213
+ selfId: () => ttyOf(process.pid, exec),
214
+ async isFront(id) {
215
+ const out = await osa(`
216
+ if application "Terminal" is not running then return "false"
217
+ tell application "Terminal"
218
+ if not frontmost then return "false"
219
+ return ((tty of selected tab of front window) is ${asString(id)}) as text
220
+ end tell`);
221
+ return out.trim() === "true";
222
+ },
223
+ };
224
+ }
225
+
226
+ // ── iTerm2 ──────────────────────────────────────────────────────────────────
227
+
228
+ const findSession = (tty: string, body: string): string => `
229
+ if application id "com.googlecode.iterm2" is not running then return "missing"
230
+ tell application "iTerm2"
231
+ repeat with w in windows
232
+ repeat with t in tabs of w
233
+ repeat with s in sessions of t
234
+ if tty of s is ${asString(tty)} then
235
+ ${body}
236
+ return "ok"
237
+ end if
238
+ end repeat
239
+ end repeat
240
+ end repeat
241
+ end tell
242
+ return "missing"`;
243
+
244
+ export function iTerm(exec: Exec = execProgram): TerminalBackend {
245
+ const osa = (script: string) => exec("osascript", ["-e", script]);
246
+ return {
247
+ app: "iTerm.app",
248
+ label: "iTerm2",
249
+ async open(req) {
250
+ const out = await osa(`
251
+ tell application "iTerm2"
252
+ set w to (create window with default profile)
253
+ tell current session of w
254
+ set name to ${asString(req.name)}
255
+ write text ${asString(shellLine(req.cwd, req.command))}
256
+ set theTty to tty
257
+ end tell
258
+ ${req.focus ? "activate" : ""}
259
+ return theTty
260
+ end tell`);
261
+ const tty = out.match(TTY)?.[0];
262
+ if (!tty) throw new Error(`iTerm2 opened a window but did not say which (${out.trim() || "no output"})`);
263
+ return tty;
264
+ },
265
+ async focus(id) {
266
+ return (await osa(findSession(id, "select w\n tell t to select\n tell s to select\n activate"))).trim() === "ok";
267
+ },
268
+ async close(id) {
269
+ // A session's `id` is iTerm2's own unique identifier for it.
270
+ const bySession = (sid: string, body: string) => `
271
+ if application id "com.googlecode.iterm2" is not running then return "missing"
272
+ tell application "iTerm2"
273
+ repeat with w in windows
274
+ repeat with t in tabs of w
275
+ repeat with s in sessions of t
276
+ if id of s is ${asString(sid)} then
277
+ ${body}
278
+ return "ok"
279
+ end if
280
+ end repeat
281
+ end repeat
282
+ end repeat
283
+ end tell
284
+ return "missing"`;
285
+ return closeChecked(id, exec, "iTerm2", {
286
+ handle: () => osa(findSession(id, "return id of s")),
287
+ close: (sid) => osa(bySession(sid, "tell s to close")),
288
+ exists: async (sid) => (await osa(bySession(sid, ""))).trim() === "ok",
289
+ });
290
+ },
291
+ async live() {
292
+ const out = await osa(`
293
+ if application id "com.googlecode.iterm2" is not running then return ""
294
+ tell application "iTerm2" to return tty of every session of every tab of every window`);
295
+ return new Set(out.match(TTY) ?? []);
296
+ },
297
+ selfId: () => ttyOf(process.pid, exec),
298
+ async isFront(id) {
299
+ const out = await osa(`
300
+ if application id "com.googlecode.iterm2" is not running then return "false"
301
+ tell application "iTerm2"
302
+ if not frontmost then return "false"
303
+ return ((tty of current session of current window) is ${asString(id)}) as text
304
+ end tell`);
305
+ return out.trim() === "true";
306
+ },
307
+ };
308
+ }
309
+
310
+ // ── tmux ────────────────────────────────────────────────────────────────────
311
+
312
+ // Ids are pane ids (%12): stable for the pane's life and unique across sessions, where
313
+ // window indexes renumber. The command is typed into a shell rather than given to
314
+ // new-window, so the window outlives the command exactly as it does on macOS.
315
+ export function tmux(exec: Exec = execProgram, env: NodeJS.ProcessEnv = process.env): TerminalBackend {
316
+ const run = (...args: string[]) => exec("tmux", args);
317
+ const exists = async (id: string) => (await run("list-panes", "-a", "-F", "#{pane_id}")).split(/\s+/).includes(id);
318
+ return {
319
+ app: "tmux",
320
+ label: "tmux",
321
+ async open(req) {
322
+ const id = (
323
+ await run("new-window", ...(req.focus ? [] : ["-d"]), "-P", "-F", "#{pane_id}", "-n", req.name, "-c", req.cwd)
324
+ ).trim();
325
+ if (!/^%\d+$/.test(id)) throw new Error(`tmux opened a window but did not say which (${id || "no output"})`);
326
+ if (req.command?.trim()) {
327
+ await run("send-keys", "-t", id, "-l", req.command.trim());
328
+ await run("send-keys", "-t", id, "Enter");
329
+ }
330
+ return id;
331
+ },
332
+ async focus(id) {
333
+ if (!(await exists(id))) return false;
334
+ // A pane in another session needs the client moved there first.
335
+ await run("switch-client", "-t", id).catch(() => undefined);
336
+ await run("select-window", "-t", id);
337
+ await run("select-pane", "-t", id);
338
+ return true;
339
+ },
340
+ async close(id) {
341
+ if (!(await exists(id))) return false;
342
+ await run("kill-window", "-t", id);
343
+ return true;
344
+ },
345
+ async live() {
346
+ return new Set((await run("list-panes", "-a", "-F", "#{pane_id}")).split(/\s+/).filter(Boolean));
347
+ },
348
+ selfId: async () => env.TMUX_PANE || undefined,
349
+ // tmux knows whether the pane is the one showing, not whether the app drawing it is
350
+ // in front: a pane that isn't showing is behind, one that is can't be sure.
351
+ async isFront(id) {
352
+ const out = (await run("display-message", "-p", "-t", id, "#{pane_active}#{window_active}")).trim();
353
+ return out === "11" ? undefined : false;
354
+ },
355
+ };
356
+ }
357
+
358
+ /**
359
+ * The backend for the window this process is in, or why there isn't one. tmux first:
360
+ * inside it, TERM_PROGRAM names the app that draws tmux, not the thing we can drive.
361
+ */
362
+ export function detectBackend(
363
+ env: NodeJS.ProcessEnv = process.env,
364
+ platform: NodeJS.Platform = process.platform,
365
+ exec: Exec = execProgram,
366
+ ): TerminalBackend | { unsupported: string } {
367
+ if (env.TMUX) return tmux(exec, env);
368
+ if (platform === "darwin" && env.TERM_PROGRAM === "Apple_Terminal") return appleTerminal(exec);
369
+ if (platform === "darwin" && env.TERM_PROGRAM === "iTerm.app") return iTerm(exec);
370
+ const where = env.TERM_PROGRAM ? `${env.TERM_PROGRAM}` : platform === "win32" ? "this Windows terminal" : "this terminal";
371
+ return {
372
+ unsupported:
373
+ `Named terminals work inside tmux, macOS Terminal and iTerm2 — not in ${where}. ` +
374
+ `Run privateer inside tmux to use them here.`,
375
+ };
376
+ }
@@ -0,0 +1,200 @@
1
+ // Named terminals: what the `terminal` tool and /term both do, so the agent and the
2
+ // person at the keyboard share one vocabulary — "open api", "go to api", "close api".
3
+ //
4
+ // The window a Privateer runs in names itself on first use (see claimSelf): its folder's
5
+ // name, or the name the agent gave it when it opened it. That is what makes "go back to
6
+ // main" possible from a window the agent opened, and what lets the user hop between
7
+ // windows they opened by hand once a session in each has started.
8
+
9
+ import { basename } from "node:path";
10
+ import { detectBackend, type TerminalBackend } from "./backends.ts";
11
+ import {
12
+ checkName,
13
+ findTerminal,
14
+ forgetTerminal,
15
+ freeName,
16
+ listTerminals,
17
+ saveTerminal,
18
+ type TerminalRecord,
19
+ } from "./registry.ts";
20
+
21
+ export type Outcome = { ok: true; message: string } | { ok: false; message: string };
22
+
23
+ const fail = (message: string): Outcome => ({ ok: false, message });
24
+ const done = (message: string): Outcome => ({ ok: true, message });
25
+
26
+ let cachedBackend: TerminalBackend | { unsupported: string } | undefined;
27
+ /** Fixed for the life of the process: the window we were started in doesn't change. */
28
+ export function backend(): TerminalBackend | { unsupported: string } {
29
+ return (cachedBackend ??= detectBackend());
30
+ }
31
+ /** Tests only. */
32
+ export function setBackendForTests(b: TerminalBackend | { unsupported: string } | undefined): void {
33
+ cachedBackend = b;
34
+ }
35
+
36
+ const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
37
+
38
+ /**
39
+ * This process's own window, registered if it isn't yet. A window the agent opened
40
+ * already has a record under its tty, so a Privateer started there keeps that name.
41
+ */
42
+ export async function claimSelf(b: TerminalBackend, cwd: string): Promise<TerminalRecord | undefined> {
43
+ const id = await b.selfId();
44
+ if (!id) return undefined;
45
+ const records = await listTerminals(b);
46
+ const mine = records.find((r) => r.id === id);
47
+ if (mine) return mine;
48
+ const base = checkName(basename(cwd));
49
+ const rec: TerminalRecord = {
50
+ name: freeName("name" in base ? base.name : "main", records),
51
+ app: b.app,
52
+ id,
53
+ cwd,
54
+ createdAt: Date.now(),
55
+ };
56
+ saveTerminal(rec);
57
+ return rec;
58
+ }
59
+
60
+ export async function openTerminal(opts: {
61
+ name: string;
62
+ cwd: string;
63
+ command?: string;
64
+ focus?: boolean;
65
+ }): Promise<Outcome> {
66
+ const b = backend();
67
+ if ("unsupported" in b) return fail(b.unsupported);
68
+ const checked = checkName(opts.name);
69
+ if ("error" in checked) return fail(checked.error);
70
+ const records = await listTerminals(b);
71
+ const clash = findTerminal(records, checked.name);
72
+ if (clash) {
73
+ return fail(
74
+ `There is already a terminal named "${clash.name}". Focus it, close it first, or pick another name.`,
75
+ );
76
+ }
77
+ // Register where we are first, so the new window can be left for this one by name.
78
+ await claimSelf(b, opts.cwd).catch(() => undefined);
79
+ const focus = opts.focus !== false;
80
+ let id: string;
81
+ try {
82
+ id = await b.open({ name: checked.name, cwd: opts.cwd, ...(opts.command ? { command: opts.command } : {}), focus });
83
+ } catch (e) {
84
+ return fail(`Could not open a ${b.label} window: ${errorText(e)}`);
85
+ }
86
+ saveTerminal({
87
+ name: checked.name,
88
+ app: b.app,
89
+ id,
90
+ cwd: opts.cwd,
91
+ ...(opts.command?.trim() ? { command: opts.command.trim() } : {}),
92
+ createdAt: Date.now(),
93
+ });
94
+ // macOS puts a new Terminal window in front whatever we ask; put ours back.
95
+ if (!focus) {
96
+ const self = await b.selfId().catch(() => undefined);
97
+ if (self) await b.focus(self).catch(() => undefined);
98
+ }
99
+ const what = opts.command?.trim() ? `running \`${opts.command.trim()}\`` : "with a shell";
100
+ return done(`Opened "${checked.name}" ${what} in ${opts.cwd}${focus ? " — it has focus now" : " in the background"}.`);
101
+ }
102
+
103
+ export async function focusTerminal(name: string, cwd: string): Promise<Outcome> {
104
+ const b = backend();
105
+ if ("unsupported" in b) return fail(b.unsupported);
106
+ await claimSelf(b, cwd).catch(() => undefined);
107
+ const records = await listTerminals(b);
108
+ const rec = findTerminal(records, name);
109
+ if (!rec) return fail(unknownName(name, records));
110
+ let ok: boolean;
111
+ try {
112
+ ok = await b.focus(rec.id);
113
+ } catch (e) {
114
+ return fail(`Could not focus "${rec.name}": ${errorText(e)}`);
115
+ }
116
+ if (!ok) {
117
+ forgetTerminal(rec.app, rec.id);
118
+ return fail(`"${rec.name}" has been closed.`);
119
+ }
120
+ return done(`Switched to "${rec.name}".`);
121
+ }
122
+
123
+ export async function closeTerminal(name: string, cwd: string): Promise<Outcome> {
124
+ const b = backend();
125
+ if ("unsupported" in b) return fail(b.unsupported);
126
+ const self = await claimSelf(b, cwd).catch(() => undefined);
127
+ const records = await listTerminals(b);
128
+ const rec = findTerminal(records, name);
129
+ if (!rec) return fail(unknownName(name, records));
130
+ if (self && rec.id === self.id) return fail(`"${rec.name}" is this terminal — it can't close itself.`);
131
+ try {
132
+ if (!(await b.close(rec.id))) {
133
+ forgetTerminal(rec.app, rec.id);
134
+ return fail(`"${rec.name}" was already closed.`);
135
+ }
136
+ } catch (e) {
137
+ return fail(`Could not close "${rec.name}": ${errorText(e)}`);
138
+ }
139
+ forgetTerminal(rec.app, rec.id);
140
+ return done(`Closed "${rec.name}".`);
141
+ }
142
+
143
+ export async function renameSelf(name: string, cwd: string): Promise<Outcome> {
144
+ const b = backend();
145
+ if ("unsupported" in b) return fail(b.unsupported);
146
+ const checked = checkName(name);
147
+ if ("error" in checked) return fail(checked.error);
148
+ const self = await claimSelf(b, cwd);
149
+ if (!self) return fail("Can't tell which window this is, so it can't be named.");
150
+ const clash = findTerminal(await listTerminals(b), checked.name);
151
+ if (clash && clash.id !== self.id) return fail(`"${clash.name}" is already another terminal's name.`);
152
+ saveTerminal({ ...self, name: checked.name });
153
+ return done(`This terminal is "${checked.name}" now.`);
154
+ }
155
+
156
+ /**
157
+ * Is the window this process runs in the one in front? For voice (privateer-speak): a
158
+ * new window can't learn that from the terminal until focus next changes. undefined
159
+ * when it can't tell — an unsupported terminal, or no tty.
160
+ */
161
+ export async function thisTerminalIsFront(): Promise<boolean | undefined> {
162
+ const b = backend();
163
+ if ("unsupported" in b) return undefined;
164
+ try {
165
+ const id = await b.selfId();
166
+ return id ? await b.isFront(id) : undefined;
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ export interface TerminalListing {
173
+ records: TerminalRecord[];
174
+ selfId?: string;
175
+ }
176
+
177
+ export async function terminalListing(cwd: string): Promise<TerminalListing | { unsupported: string }> {
178
+ const b = backend();
179
+ if ("unsupported" in b) return b;
180
+ const self = await claimSelf(b, cwd).catch(() => undefined);
181
+ return { records: await listTerminals(b), ...(self ? { selfId: self.id } : {}) };
182
+ }
183
+
184
+ export function describeListing(l: TerminalListing): string {
185
+ if (!l.records.length) return "No named terminals yet.";
186
+ return l.records
187
+ .map((r) => {
188
+ const here = r.id === l.selfId ? " ← this one" : "";
189
+ const what = r.command ? ` — ${r.command}` : "";
190
+ return `${r.name}: ${r.cwd}${what}${here}`;
191
+ })
192
+ .join("\n");
193
+ }
194
+
195
+ function unknownName(name: string, records: readonly TerminalRecord[]): string {
196
+ const names = records.map((r) => r.name);
197
+ return names.length
198
+ ? `No terminal named "${name}". Named terminals: ${names.join(", ")}.`
199
+ : `No terminal named "${name}", and none are open yet.`;
200
+ }
@@ -0,0 +1,107 @@
1
+ // Named terminals: which name means which window, shared by every Privateer on this
2
+ // machine. One agent opens "api"; a second agent, or the user's /term in any window,
3
+ // can bring "api" to the front. So the record lives in the global dir, not in the
4
+ // session.
5
+ //
6
+ // A record is (app, id) → name. Ids are ttys or tmux pane ids, and both get reused once
7
+ // their window is gone, so records are pruned against what the backend says exists
8
+ // before they are trusted (see listTerminals) — a stale "api" must not focus whatever
9
+ // unrelated window inherited its tty.
10
+
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { globalDir } from "../config/paths.ts";
14
+ import type { TerminalApp, TerminalBackend } from "./backends.ts";
15
+
16
+ export interface TerminalRecord {
17
+ name: string;
18
+ app: TerminalApp;
19
+ /** The window's tty (/dev/ttys004) or tmux pane id (%12). */
20
+ id: string;
21
+ /** The folder it was opened in. */
22
+ cwd: string;
23
+ /** What the agent started in it, if anything. */
24
+ command?: string;
25
+ /** Epoch ms. */
26
+ createdAt: number;
27
+ }
28
+
29
+ export function registryPath(): string {
30
+ return join(globalDir(), "terminals.json");
31
+ }
32
+
33
+ export function readRegistry(): TerminalRecord[] {
34
+ try {
35
+ const parsed = JSON.parse(readFileSync(registryPath(), "utf8"));
36
+ const list = Array.isArray(parsed?.terminals) ? parsed.terminals : [];
37
+ return list.filter(
38
+ (r: any) => r && typeof r.name === "string" && typeof r.id === "string" && typeof r.app === "string",
39
+ );
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ function writeRegistry(records: TerminalRecord[]): void {
46
+ const dir = globalDir();
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
48
+ // Rename over the old file so a second Privateer reading at the same moment sees the
49
+ // old list or the new one, never half of each.
50
+ const tmp = `${registryPath()}.${process.pid}.tmp`;
51
+ writeFileSync(tmp, JSON.stringify({ terminals: records }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
52
+ renameSync(tmp, registryPath());
53
+ }
54
+
55
+ const MAX_NAME = 40;
56
+
57
+ /** A usable name, or why not. Names are matched without regard to case. */
58
+ export function checkName(raw: string): { name: string } | { error: string } {
59
+ const name = raw.trim().replace(/\s+/g, " ");
60
+ if (!name) return { error: "A terminal needs a name." };
61
+ if (name.length > MAX_NAME) return { error: `Keep terminal names to ${MAX_NAME} characters.` };
62
+ if (!/^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u.test(name)) {
63
+ return { error: "Terminal names are letters, digits, spaces, dots, dashes and underscores." };
64
+ }
65
+ return { name };
66
+ }
67
+
68
+ export const sameName = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase();
69
+
70
+ /** `base`, or `base-2`, `base-3`… — the first not already taken. */
71
+ export function freeName(base: string, taken: readonly TerminalRecord[]): string {
72
+ const used = (n: string) => taken.some((r) => sameName(r.name, n));
73
+ if (!used(base)) return base;
74
+ for (let i = 2; ; i++) if (!used(`${base}-${i}`)) return `${base}-${i}`;
75
+ }
76
+
77
+ /** Records for windows that still exist on this backend. Others' records are kept. */
78
+ export async function listTerminals(backend: TerminalBackend): Promise<TerminalRecord[]> {
79
+ const all = readRegistry();
80
+ let live: Set<string>;
81
+ try {
82
+ live = await backend.live();
83
+ } catch {
84
+ return all.filter((r) => r.app === backend.app); // can't check: trust, don't prune
85
+ }
86
+ const kept = all.filter((r) => r.app !== backend.app || live.has(r.id));
87
+ if (kept.length !== all.length) writeRegistry(kept);
88
+ return kept.filter((r) => r.app === backend.app);
89
+ }
90
+
91
+ export function findTerminal(records: readonly TerminalRecord[], name: string): TerminalRecord | undefined {
92
+ return records.find((r) => sameName(r.name, name));
93
+ }
94
+
95
+ /** Add or replace: one record per window, one window per name. */
96
+ export function saveTerminal(rec: TerminalRecord): void {
97
+ const others = readRegistry().filter(
98
+ (r) => !(r.app === rec.app && r.id === rec.id) && !(r.app === rec.app && sameName(r.name, rec.name)),
99
+ );
100
+ writeRegistry([...others, rec]);
101
+ }
102
+
103
+ export function forgetTerminal(app: TerminalApp, id: string): void {
104
+ const all = readRegistry();
105
+ const kept = all.filter((r) => !(r.app === app && r.id === id));
106
+ if (kept.length !== all.length) writeRegistry(kept);
107
+ }