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.
- package/README.md +37 -2
- package/extensions/privateer-brand.ts +2 -2
- package/extensions/privateer-gate.ts +17 -3
- package/extensions/privateer-media.ts +6 -0
- package/extensions/privateer-speak.ts +9 -1
- package/extensions/privateer-terminals.ts +73 -0
- package/package.json +2 -2
- package/src/config/mediaModels.ts +64 -0
- package/src/config/moatManifest.json +1 -0
- package/src/permissions/classify.ts +22 -0
- package/src/terminals/backends.ts +376 -0
- package/src/terminals/index.ts +200 -0
- package/src/terminals/registry.ts +107 -0
- package/src/tools/media.ts +44 -17
- package/src/tools/mediaModelCommands.ts +175 -0
- package/src/tools/terminal.ts +80 -0
|
@@ -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
|
+
}
|