privateer-agent 0.12.58 → 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-speak.ts +9 -1
- package/extensions/privateer-terminals.ts +73 -0
- package/package.json +2 -2
- 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/terminal.ts +80 -0
package/README.md
CHANGED
|
@@ -126,6 +126,9 @@ silently. The moat is swappable; the floor under it holds.
|
|
|
126
126
|
and the mic closes when you stop talking. Your OS voice by default — nothing leaves the
|
|
127
127
|
machine — or your account's confidential-compute TTS/STT once you sign in. `/talk loop on`
|
|
128
128
|
makes it hands-free. See [Talk to it](#talk-to-it--voice-both-directions).
|
|
129
|
+
- **Named terminals.** The agent can open windows you watch — a dev server, a REPL, a second
|
|
130
|
+
agent — give each a name, and bring any of them to the front; `/term api` switches you there
|
|
131
|
+
yourself. macOS Terminal, iTerm2, and tmux anywhere. See [Named terminals](#named-terminals).
|
|
129
132
|
- **MCP servers, sub-agents & skills.** Connect Model Context Protocol servers (local stdio
|
|
130
133
|
or remote HTTP with OAuth) with [`/connect`](#connectors--mcp), delegate work to bounded
|
|
131
134
|
parallel sub-agents, and drop in skills — all gated like everything else.
|
|
@@ -359,8 +362,9 @@ One caveat while the account endpoints catch up: `/speak rate` and `/talk vocab`
|
|
|
359
362
|
by the local and OpenAI-compatible engines, not yet by the account's TTS/STT.
|
|
360
363
|
|
|
361
364
|
Voice is **off by default and interactive-only** — harbor, ACP and channel sessions never
|
|
362
|
-
speak. The mic opens only on something you did (`/talk`, the push-to-talk key,
|
|
363
|
-
conversation turn you switched on
|
|
365
|
+
speak. The mic opens only on something you did (`/talk`, the push-to-talk key, a
|
|
366
|
+
conversation turn you switched on, or switching to a window while dictation or conversation
|
|
367
|
+
mode is on); there is no wake word, capture is hard-capped, audio is
|
|
364
368
|
held in memory and never written to disk, and a mis-heard transcript is still just a prompt —
|
|
365
369
|
every tool call it leads to hits the same permission gate as anything you type. Recording
|
|
366
370
|
needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` on Linux,
|
|
@@ -368,6 +372,37 @@ needs a capture tool on PATH: `sox` anywhere, or `arecord`/`parecord`/`ffmpeg` o
|
|
|
368
372
|
standalone [`privateer-speak`](https://www.npmjs.com/package/privateer-speak) package, usable
|
|
369
373
|
in any Pi agent.
|
|
370
374
|
|
|
375
|
+
## Named terminals
|
|
376
|
+
|
|
377
|
+
Ask for "a terminal running the dev server" and the agent opens a new window, names it, and
|
|
378
|
+
puts it in front of you. Every Privateer window names itself too (after its folder, or the
|
|
379
|
+
name the agent gave it), so you and the agent can hop between them by name:
|
|
380
|
+
|
|
381
|
+
```
|
|
382
|
+
/term pick a terminal to switch to
|
|
383
|
+
/term api switch to "api"
|
|
384
|
+
/term open api npm run dev open one yourself
|
|
385
|
+
/term name backend rename this window
|
|
386
|
+
/term close api close it, ending what runs there
|
|
387
|
+
/term list every named terminal (← marks this one)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
The agent does the same through its `terminal` tool. Opening a window with a command goes
|
|
391
|
+
through the permission gate exactly like running that command in bash; switching and listing
|
|
392
|
+
don't ask. The agent can't read what a window prints: that's what bash and background tasks
|
|
393
|
+
are for. These windows are for you.
|
|
394
|
+
|
|
395
|
+
Voice follows you between windows. Every window shares one voice setup, so with
|
|
396
|
+
dictation or conversation mode on, the window you switch to listens for your next prompt,
|
|
397
|
+
including a new agent the moment its window opens in front. The window you left pauses
|
|
398
|
+
its mic. (That's [privateer-speak](https://www.npmjs.com/package/privateer-speak)'s focus
|
|
399
|
+
awareness; it needs a terminal that reports focus.)
|
|
400
|
+
|
|
401
|
+
Works in **macOS Terminal**, **iTerm2**, and inside **tmux** on any platform; other terminals
|
|
402
|
+
get told so. macOS may ask once for permission to let the terminal control itself. Names are
|
|
403
|
+
shared across every Privateer on the machine (`~/.privateer/terminals.json`), and a window
|
|
404
|
+
that's been closed drops off the list on its own.
|
|
405
|
+
|
|
371
406
|
## The Privateer app
|
|
372
407
|
|
|
373
408
|
The same account drives Privateer from **iOS, Android, [the web app](https://privateer.pro),
|
|
@@ -285,10 +285,10 @@ function contextLine(p: Palette): string {
|
|
|
285
285
|
// or three things a returning user should notice. `cmd`, when present, is rendered in the
|
|
286
286
|
// accent color so the actionable bit stands out from the prose. Trim this as it ages.
|
|
287
287
|
const WHATS_NEW: Array<{ text: string; cmd?: string }> = [
|
|
288
|
+
{ text: "Open and switch named terminals —", cmd: "/term" },
|
|
289
|
+
{ text: "Voice follows the window you're in" },
|
|
288
290
|
{ text: "Searchable model picker —", cmd: "/models" },
|
|
289
291
|
{ text: "Start over with a new agent —", cmd: "/fresh" },
|
|
290
|
-
{ text: "Text-only models can read images now" },
|
|
291
|
-
{ text: "Live crypto prices and Solana wallets" },
|
|
292
292
|
];
|
|
293
293
|
|
|
294
294
|
function whatsNewRows(p: Palette): string[] {
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
} from "privateer-speak";
|
|
62
62
|
import { apiRequest, hasCredentials } from "../src/auth/privateer.ts";
|
|
63
63
|
import { globalDir } from "../src/config/paths.ts";
|
|
64
|
+
import { thisTerminalIsFront } from "../src/terminals/index.ts";
|
|
64
65
|
import { VOICE_DESCRIPTIONS } from "./speak-voices.data.ts";
|
|
65
66
|
|
|
66
67
|
const QWEN3_TTS_VOICES = ["serena", "aiden", "dylan", "eric", "ono_anna", "ryan", "sohee", "uncle_fu", "vivian"];
|
|
@@ -358,7 +359,14 @@ export default function privateerSpeak(pi: any): void {
|
|
|
358
359
|
// voiceCommands: saying one of these AS THE WHOLE UTTERANCE runs /fresh (see
|
|
359
360
|
// extensions/privateer-fresh.ts) instead of sending the words to the model. Whole
|
|
360
361
|
// utterance only, so "why did the fresh start fail" is still a question.
|
|
361
|
-
|
|
362
|
+
// isFocused: a window that has just started (say, one the agent opened with the
|
|
363
|
+
// terminal tool) asks Terminal/iTerm2 whether it's in front, so a hands-free mode
|
|
364
|
+
// carries on in the new agent without a keypress — see src/terminals/.
|
|
365
|
+
makePiSpeakExtension({
|
|
366
|
+
configFile: join(globalDir(), "speak.json"),
|
|
367
|
+
voiceCommands: VOICE_COMMANDS,
|
|
368
|
+
isFocused: thisTerminalIsFront,
|
|
369
|
+
})(pi);
|
|
362
370
|
}
|
|
363
371
|
|
|
364
372
|
const VOICE_COMMANDS: Record<string, string> = {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Named terminals — the `terminal` tool and /term, so the agent can open windows the
|
|
2
|
+
// user watches (a dev server, a REPL, a second agent) and either of them can switch
|
|
3
|
+
// between those windows by name. See src/terminals/ for how windows are found and why
|
|
4
|
+
// only tmux, macOS Terminal and iTerm2 are driven.
|
|
5
|
+
//
|
|
6
|
+
// Interactive only: the window registers itself at session start, which a harbor or ACP
|
|
7
|
+
// host has no window to do, and the tool refuses without a UI.
|
|
8
|
+
|
|
9
|
+
import { closeTerminal, describeListing, focusTerminal, openTerminal, renameSelf, terminalListing, backend, claimSelf } from "../src/terminals/index.ts";
|
|
10
|
+
import { terminalToolDefinition } from "../src/tools/terminal.ts";
|
|
11
|
+
|
|
12
|
+
const USAGE =
|
|
13
|
+
"/term — pick a terminal to switch to · /term <name> — switch · /term open <name> [command] · " +
|
|
14
|
+
"/term name <name> — rename this one · /term close <name> · /term list";
|
|
15
|
+
|
|
16
|
+
export default function privateerTerminals(pi: any): void {
|
|
17
|
+
pi.registerTool?.(terminalToolDefinition);
|
|
18
|
+
|
|
19
|
+
// Name this window as soon as the session starts, so it can be switched back to from
|
|
20
|
+
// any other — not only after its first /term.
|
|
21
|
+
pi.on?.("session_start", (_e: any, ctx: any) => {
|
|
22
|
+
if (!ctx?.hasUI) return;
|
|
23
|
+
const b = backend();
|
|
24
|
+
if ("unsupported" in b) return;
|
|
25
|
+
void claimSelf(b, ctx.cwd ?? process.cwd()).catch(() => undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
pi.registerCommand?.("term", {
|
|
29
|
+
description: "Switch between named terminals, or open one",
|
|
30
|
+
handler: async (args: string, ctx: any) => {
|
|
31
|
+
const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
|
|
32
|
+
const cwd = ctx?.cwd ?? process.cwd();
|
|
33
|
+
const [sub = "", ...rest] = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
34
|
+
const report = (o: { ok: boolean; message: string }) => notify(o.message, o.ok ? "info" : "warning");
|
|
35
|
+
|
|
36
|
+
switch (sub.toLowerCase()) {
|
|
37
|
+
case "": {
|
|
38
|
+
const l = await terminalListing(cwd);
|
|
39
|
+
if ("unsupported" in l) return notify(l.unsupported, "warning");
|
|
40
|
+
const others = l.records.filter((r) => r.id !== l.selfId);
|
|
41
|
+
if (!others.length) return notify(`No other named terminals. ${USAGE}`);
|
|
42
|
+
if (typeof ctx?.ui?.select !== "function") return notify(describeListing(l));
|
|
43
|
+
const labels = others.map((r) => `${r.name} ${r.command ?? r.cwd}`);
|
|
44
|
+
const picked = await ctx.ui.select("Switch to terminal", labels);
|
|
45
|
+
const rec = picked ? others[labels.indexOf(picked)] : undefined;
|
|
46
|
+
if (rec) report(await focusTerminal(rec.name, cwd));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
case "list": {
|
|
50
|
+
const l = await terminalListing(cwd);
|
|
51
|
+
return notify("unsupported" in l ? l.unsupported : describeListing(l), "unsupported" in l ? "warning" : "info");
|
|
52
|
+
}
|
|
53
|
+
case "open": {
|
|
54
|
+
const [name, ...cmd] = rest;
|
|
55
|
+
if (!name) return notify("Usage: /term open <name> [command]", "warning");
|
|
56
|
+
return report(await openTerminal({ name, cwd, ...(cmd.length ? { command: cmd.join(" ") } : {}) }));
|
|
57
|
+
}
|
|
58
|
+
case "name":
|
|
59
|
+
case "rename":
|
|
60
|
+
if (!rest.length) return notify("Usage: /term name <name>", "warning");
|
|
61
|
+
return report(await renameSelf(rest.join(" "), cwd));
|
|
62
|
+
case "close":
|
|
63
|
+
if (!rest.length) return notify("Usage: /term close <name>", "warning");
|
|
64
|
+
return report(await closeTerminal(rest.join(" "), cwd));
|
|
65
|
+
case "help":
|
|
66
|
+
return notify(USAGE);
|
|
67
|
+
default:
|
|
68
|
+
// Anything else is a name: /term api, /term "dev server".
|
|
69
|
+
return report(await focusTerminal([sub, ...rest].join(" "), cwd));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"pi-privacy": "0.14.1",
|
|
87
87
|
"pi-subagents": "0.70.0",
|
|
88
88
|
"picomatch": "4.0.5",
|
|
89
|
-
"privateer-speak": "0.
|
|
89
|
+
"privateer-speak": "0.7.0",
|
|
90
90
|
"privateer-workflow": "0.1.0",
|
|
91
91
|
"tinfoil": "1.1.11",
|
|
92
92
|
"tsx": "4.23.0",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
{ "entry": "extensions/privateer-media.ts", "name": "privateer-media", "note": "image/video/speech/music + ffmpeg compose" },
|
|
21
21
|
{ "entry": "extensions/privateer-computer.ts", "name": "privateer-computer", "note": "screen control — screenshots, mouse and keyboard (off unless armed with --allow-computer-control)" },
|
|
22
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
23
|
+
{ "entry": "extensions/privateer-terminals.ts", "name": "privateer-terminals", "note": "named terminal windows — the terminal tool + /term to switch between them" },
|
|
23
24
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
24
25
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
25
26
|
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
@@ -648,6 +648,28 @@ export function classifyToolCall(
|
|
|
648
648
|
};
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
// terminal (src/tools/terminal.ts) — named windows the user watches. Opening one
|
|
652
|
+
// with a command RUNS that command, so it is bash-kind with the command as the
|
|
653
|
+
// detail: the same danger list, allowlist and plan-mode refusal as typing it into
|
|
654
|
+
// bash. A bare shell is bash-kind too (it puts a new window on the user's screen),
|
|
655
|
+
// and so is close, which ends whatever runs in the window. Focus and list only move
|
|
656
|
+
// a window to the front or read names: no gate, in any mode.
|
|
657
|
+
if (name === "terminal") {
|
|
658
|
+
const action = str(obj.action) || "list";
|
|
659
|
+
if (action === "focus" || action === "list") return null;
|
|
660
|
+
const label = str(obj.name);
|
|
661
|
+
if (action === "close") {
|
|
662
|
+
return { tool: toolName, kind: "bash", title: `Close terminal "${label}"`, detail: `close terminal ${label}` };
|
|
663
|
+
}
|
|
664
|
+
const command = str(obj.command).trim();
|
|
665
|
+
return {
|
|
666
|
+
tool: toolName,
|
|
667
|
+
kind: "bash",
|
|
668
|
+
title: command ? `Open terminal "${label}" running a command` : `Open terminal "${label}"`,
|
|
669
|
+
detail: command || `open a shell${obj.cwd ? ` in ${str(obj.cwd)}` : ""}`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
651
673
|
// Network reads (web fetch / search / http).
|
|
652
674
|
if (name.includes("fetch") || name.includes("web") || name.includes("http") || name.includes("url")) {
|
|
653
675
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `terminal` — open, focus, list and close named terminal windows the user can see.
|
|
2
|
+
// The machinery and its limits are in src/terminals/; this is the model-facing surface.
|
|
3
|
+
//
|
|
4
|
+
// The gate (permissions/classify.ts) treats `open` with a command exactly like bash —
|
|
5
|
+
// it IS running that command, just in a window the user watches — and `close` as
|
|
6
|
+
// bash-kind too, since it ends whatever runs there. `focus` and `list` are not gated:
|
|
7
|
+
// they change which window is in front and read a list of names.
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
import {
|
|
11
|
+
closeTerminal,
|
|
12
|
+
describeListing,
|
|
13
|
+
focusTerminal,
|
|
14
|
+
openTerminal,
|
|
15
|
+
terminalListing,
|
|
16
|
+
} from "../terminals/index.ts";
|
|
17
|
+
|
|
18
|
+
function text(t: string) {
|
|
19
|
+
return { content: [{ type: "text", text: t }], details: {} };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ACTIONS = ["open", "focus", "list", "close"] as const;
|
|
23
|
+
type Action = (typeof ACTIONS)[number];
|
|
24
|
+
|
|
25
|
+
export const terminalToolDefinition = {
|
|
26
|
+
name: "terminal",
|
|
27
|
+
label: "Terminal",
|
|
28
|
+
description:
|
|
29
|
+
"Open, switch between, list and close named terminal windows on the user's screen. " +
|
|
30
|
+
"`open` starts a new window with a name (and optionally a command, typed into its shell — a dev server, " +
|
|
31
|
+
"a REPL, a log tail, another `privateer`), and gives it focus unless focus is false. `focus` brings a named " +
|
|
32
|
+
"terminal to the front — including the one you are running in (see `list`), so you can send the user back. " +
|
|
33
|
+
"`list` shows every named terminal and marks this one. `close` closes a window and ends what runs in it. " +
|
|
34
|
+
"You can NOT read a terminal's output: for commands whose output you need, use bash or a background task. " +
|
|
35
|
+
"Use this when the user wants to watch or use something themselves, or asks to switch terminals. " +
|
|
36
|
+
"Works in macOS Terminal, iTerm2, and inside tmux.",
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
action: Type.Union(ACTIONS.map((a) => Type.Literal(a)), { description: "What to do." }),
|
|
39
|
+
name: Type.Optional(
|
|
40
|
+
Type.String({ description: "The terminal's name — required for open, focus and close. Short: \"api\", \"tests\"." }),
|
|
41
|
+
),
|
|
42
|
+
command: Type.Optional(Type.String({ description: "open: a shell command to run in the new window." })),
|
|
43
|
+
cwd: Type.Optional(Type.String({ description: "open: the folder to start in. Defaults to the current one." })),
|
|
44
|
+
focus: Type.Optional(Type.Boolean({ description: "open: bring the new window to the front (default true)." })),
|
|
45
|
+
}),
|
|
46
|
+
async execute(
|
|
47
|
+
_id: string,
|
|
48
|
+
params: { action: Action; name?: string; command?: string; cwd?: string; focus?: boolean },
|
|
49
|
+
_signal?: AbortSignal,
|
|
50
|
+
_onUpdate?: unknown,
|
|
51
|
+
ctx?: any,
|
|
52
|
+
) {
|
|
53
|
+
if (ctx && ctx.hasUI === false) return text("Terminal windows need an interactive session on the user's machine.");
|
|
54
|
+
const cwd = String(ctx?.cwd ?? process.cwd());
|
|
55
|
+
const name = String(params.name ?? "").trim();
|
|
56
|
+
const action: Action = ACTIONS.includes(params.action) ? params.action : "list";
|
|
57
|
+
if (action !== "list" && !name) return text(`Error: name is required for ${action}.`);
|
|
58
|
+
switch (action) {
|
|
59
|
+
case "open":
|
|
60
|
+
return text(
|
|
61
|
+
(
|
|
62
|
+
await openTerminal({
|
|
63
|
+
name,
|
|
64
|
+
cwd: params.cwd?.trim() || cwd,
|
|
65
|
+
...(params.command?.trim() ? { command: params.command } : {}),
|
|
66
|
+
...(params.focus === false ? { focus: false } : {}),
|
|
67
|
+
})
|
|
68
|
+
).message,
|
|
69
|
+
);
|
|
70
|
+
case "focus":
|
|
71
|
+
return text((await focusTerminal(name, cwd)).message);
|
|
72
|
+
case "close":
|
|
73
|
+
return text((await closeTerminal(name, cwd)).message);
|
|
74
|
+
case "list": {
|
|
75
|
+
const l = await terminalListing(cwd);
|
|
76
|
+
return text("unsupported" in l ? l.unsupported : describeListing(l));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|