privateer-agent 0.12.49 → 0.12.50
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 +4 -0
- package/bin/fresh-supervisor.d.mts +35 -0
- package/bin/fresh-supervisor.mjs +419 -0
- package/bin/privateer-launch.mjs +86 -14
- package/extensions/privateer-fresh.ts +59 -0
- package/extensions/privateer-speak.ts +12 -1
- package/package.json +2 -2
- package/src/config/moatManifest.json +1 -0
package/README.md
CHANGED
|
@@ -688,6 +688,8 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
688
688
|
| `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
|
|
689
689
|
| `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
|
|
690
690
|
| `/speak` · `/talk` | read answers aloud / voice input (**alt+t** is push-to-talk) |
|
|
691
|
+
| `/mood` | pick a voice mood (focused, relaxed, hands-free, quiet, or your own; `/mood save <name>` keeps the current setup) |
|
|
692
|
+
| `/fresh` | replace this agent with a new process: everything it started (dev servers, watchers, subagents) is stopped, and the new agent has no link to the old one (`/resume` still reaches the old session). Saying "fresh start" does the same |
|
|
691
693
|
| `/extensions` | list loaded Pi extensions |
|
|
692
694
|
| `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
|
|
693
695
|
| `/init` | scaffold a starter `PRIVATEER.md` in this directory |
|
|
@@ -695,6 +697,8 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
|
|
|
695
697
|
| `/update` · `/privateer` | update to the latest release / Privateer status and posture |
|
|
696
698
|
|
|
697
699
|
Shell subcommands: `privateer` (interactive), `privateer update`, `privateer harbor …`,
|
|
700
|
+
`privateer fresh [n]` (swap a running terminal's agent from another terminal, even when that
|
|
701
|
+
agent is stuck),
|
|
698
702
|
`privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
|
|
699
703
|
[`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
|
|
700
704
|
`privateer -p … [--allow-spend …] [--approve-in-app]` (see
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Types for the fresh-agent supervisor. Same reason as run-to-completion.d.mts: bin/
|
|
2
|
+
// runs under a bare `node` before any transpiler exists, but the /fresh extension and
|
|
3
|
+
// the tests that pin the reaping and arg-filtering are TypeScript.
|
|
4
|
+
|
|
5
|
+
export const FRESH_GRACE_MS: number;
|
|
6
|
+
export const FRESH_SOCKET_ENV: "PRIVATEER_FRESH_SOCKET";
|
|
7
|
+
export const FRESH_TOKEN_ENV: "PRIVATEER_FRESH_TOKEN";
|
|
8
|
+
|
|
9
|
+
export interface PsRow {
|
|
10
|
+
pid: number;
|
|
11
|
+
ppid: number;
|
|
12
|
+
pgid: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A supervised terminal, as registered in PRIVATEER_HOME/run/<pid>.json. */
|
|
16
|
+
export interface TerminalEntry {
|
|
17
|
+
pid: number;
|
|
18
|
+
childPid?: number;
|
|
19
|
+
socket: string;
|
|
20
|
+
token: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parsePs(text: string): PsRow[];
|
|
26
|
+
/** The root, its descendants, and every member of a group a descendant leads —
|
|
27
|
+
* never the supervisor's own group (`protectPgid`) as a group. */
|
|
28
|
+
export function collectTree(rows: PsRow[], rootPid: number, protectPgid?: number): { pids: number[]; groups: number[] };
|
|
29
|
+
/** The user's launch args minus session selection, the initial prompt and @files. */
|
|
30
|
+
export function filterRespawnArgs(args: string[]): string[];
|
|
31
|
+
export function runDir(home: string): string;
|
|
32
|
+
export function listTerminals(home: string): TerminalEntry[];
|
|
33
|
+
export function requestFresh(socket: string, token: string, timeoutMs?: number): Promise<{ ok: true; pid: number }>;
|
|
34
|
+
/** Run the TUI under supervision; `buildArgs(true)` for the first launch, false after. */
|
|
35
|
+
export function runSupervised(cmd: string, buildArgs: (first: boolean) => string[], opts: { home: string }): void;
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fresh agent: the launcher stays up as the TUI's supervisor, so a terminal can swap its
|
|
3
|
+
* agent for a brand-new PROCESS without the user relaunching anything.
|
|
4
|
+
*
|
|
5
|
+
* WHY A PROCESS, NOT A SESSION. Pi's own /new (and an extension's ctx.newSession()) runs
|
|
6
|
+
* in the same process. That is the wrong tool for both reasons this exists:
|
|
7
|
+
* - GARBAGE COLLECTION. A TUI can wedge, leak, or leave a pile of children behind (dev
|
|
8
|
+
* servers, watchers, subagents). A new session inherits all of it; a new process
|
|
9
|
+
* inherits none of it, because the old tree is reaped first.
|
|
10
|
+
* - ISOLATION. A subagent is a child its parent knows about. A fresh agent is not: it
|
|
11
|
+
* gets a new session file, no --continue, and nothing linking it to the one before.
|
|
12
|
+
*
|
|
13
|
+
* WHY THE LAUNCHER. It already sits above the TUI (runToCompletion), shares its
|
|
14
|
+
* terminal, and is the one process that stays responsive when the agent does not. A
|
|
15
|
+
* jammed agent can't run its own /fresh, voice command or hotkey — they all live on the
|
|
16
|
+
* event loop that is jammed — so the rescue has to come from outside it.
|
|
17
|
+
*
|
|
18
|
+
* THE CONTROL CHANNEL. Each supervised terminal listens on a local socket (a unix socket
|
|
19
|
+
* under PRIVATEER_HOME/run, a named pipe on Windows) and registers itself in
|
|
20
|
+
* PRIVATEER_HOME/run/<launcherPid>.json. Anything that knows the socket AND the random
|
|
21
|
+
* per-launch token can ask for a fresh agent: the /fresh command inside the TUI (handed
|
|
22
|
+
* both through the environment) and `privateer fresh` from another terminal (reads them
|
|
23
|
+
* from the 0600 registry file). The token is what stops another local user from
|
|
24
|
+
* restarting your agent through a pipe whose default ACL they can open.
|
|
25
|
+
*
|
|
26
|
+
* REAPING, AND WHY IT SNAPSHOTS FIRST. Pi's bash tool spawns every command detached
|
|
27
|
+
* (its own process group) on unix. The moment the TUI dies, those are reparented to init
|
|
28
|
+
* and the parent→child link that says "this dev server belonged to that agent" is gone.
|
|
29
|
+
* So the tree is read BEFORE anything is signalled: every descendant by ppid, plus every
|
|
30
|
+
* process in a group one of them leads (which catches a grandchild whose own parent
|
|
31
|
+
* already exited). SIGTERM first — Pi shuts down cleanly on it — then SIGKILL whatever
|
|
32
|
+
* is left after the grace period. The launcher's own process group is never signalled as
|
|
33
|
+
* a group: the TUI shares it (it's the terminal's foreground group), and so does the
|
|
34
|
+
* launcher. Windows keeps no process groups worth using here; `taskkill /T` walks the
|
|
35
|
+
* tree while it is still intact.
|
|
36
|
+
*
|
|
37
|
+
* Not caught: a process that double-forked AND called setsid on its own (a classic
|
|
38
|
+
* daemon) before the snapshot. It has left both the tree and the group, and nothing short
|
|
39
|
+
* of guessing by name would find it.
|
|
40
|
+
*/
|
|
41
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
42
|
+
import crypto from "node:crypto";
|
|
43
|
+
import fs from "node:fs";
|
|
44
|
+
import net from "node:net";
|
|
45
|
+
import os from "node:os";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
|
|
48
|
+
const isWin = process.platform === "win32";
|
|
49
|
+
|
|
50
|
+
/** How long the old agent gets to exit on SIGTERM before it is killed outright. */
|
|
51
|
+
export const FRESH_GRACE_MS = 3000;
|
|
52
|
+
|
|
53
|
+
/** Env the TUI (and so /fresh) receives: where its supervisor listens, and the key. */
|
|
54
|
+
export const FRESH_SOCKET_ENV = "PRIVATEER_FRESH_SOCKET";
|
|
55
|
+
export const FRESH_TOKEN_ENV = "PRIVATEER_FRESH_TOKEN";
|
|
56
|
+
|
|
57
|
+
// ── process tree (pure, tested) ─────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/** Parse `ps -A -o pid=,ppid=,pgid=` output into rows. Junk lines are skipped. */
|
|
60
|
+
export function parsePs(text) {
|
|
61
|
+
const rows = [];
|
|
62
|
+
for (const line of String(text).split("\n")) {
|
|
63
|
+
const [pid, ppid, pgid] = line.trim().split(/\s+/).map(Number);
|
|
64
|
+
if (Number.isInteger(pid) && Number.isInteger(ppid) && Number.isInteger(pgid) && pid > 0) {
|
|
65
|
+
rows.push({ pid, ppid, pgid });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Everything to stop when `rootPid` is replaced: the root, all its descendants, and
|
|
73
|
+
* every member of a process group a descendant leads. `protectPgid` is the supervisor's
|
|
74
|
+
* own group — never signalled as a group, and members of it are only included when they
|
|
75
|
+
* are real descendants of the root.
|
|
76
|
+
*
|
|
77
|
+
* Returns { pids, groups }: `groups` are signalled as -pgid (catching members we never
|
|
78
|
+
* saw), `pids` individually.
|
|
79
|
+
*/
|
|
80
|
+
export function collectTree(rows, rootPid, protectPgid) {
|
|
81
|
+
const kids = new Map();
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
if (!kids.has(r.ppid)) kids.set(r.ppid, []);
|
|
84
|
+
kids.get(r.ppid).push(r);
|
|
85
|
+
}
|
|
86
|
+
const byPid = new Map(rows.map((r) => [r.pid, r]));
|
|
87
|
+
const pids = new Set([rootPid]);
|
|
88
|
+
const queue = [rootPid];
|
|
89
|
+
while (queue.length) {
|
|
90
|
+
const p = queue.shift();
|
|
91
|
+
for (const c of kids.get(p) ?? []) {
|
|
92
|
+
if (!pids.has(c.pid)) {
|
|
93
|
+
pids.add(c.pid);
|
|
94
|
+
queue.push(c.pid);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const groups = new Set();
|
|
99
|
+
for (const p of pids) {
|
|
100
|
+
const g = byPid.get(p)?.pgid;
|
|
101
|
+
if (g && g !== protectPgid && g > 1) groups.add(g);
|
|
102
|
+
}
|
|
103
|
+
// Orphans still in one of those groups: their parent exited before the snapshot, but
|
|
104
|
+
// they are the same job and would otherwise outlive the agent that started it.
|
|
105
|
+
for (const r of rows) if (groups.has(r.pgid)) pids.add(r.pid);
|
|
106
|
+
return { pids: [...pids], groups: [...groups] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The user's own launch args, minus what would tie the new agent to the old one or
|
|
111
|
+
* replay what it was told: session selection (--continue, --resume, --session,
|
|
112
|
+
* --session-id, --fork, --name), the initial prompt, and @file attachments. Everything
|
|
113
|
+
* about HOW to run stays — model, key, tools, posture flags like --no-quarter or
|
|
114
|
+
* --allow-computer-control — because the user chose those for this terminal, and a
|
|
115
|
+
* fresh agent that silently changed its permissions would be a different surprise.
|
|
116
|
+
*
|
|
117
|
+
* Walks the args with Pi's own grammar (dist/cli/args.js), because arity is the whole
|
|
118
|
+
* problem: dropping a positional is only safe if we know it isn't the value of the flag
|
|
119
|
+
* before it. Pi's rule for a flag it doesn't know (every extension flag) is to take the
|
|
120
|
+
* next arg as its value unless it starts with "-" or "@"; so is ours.
|
|
121
|
+
*/
|
|
122
|
+
const DROP_BOOL = new Set(["-c", "--continue", "-r", "--resume"]);
|
|
123
|
+
const DROP_VALUE = new Set(["--session", "--session-id", "--fork", "--name", "-n"]);
|
|
124
|
+
const KEEP_BOOL = new Set([
|
|
125
|
+
"--no-session", "--no-tools", "--no-builtin-tools", "--print", "--no-extensions", "--no-skills",
|
|
126
|
+
"--no-prompt-templates", "--no-themes", "--no-context-files", "--verbose", "--approve",
|
|
127
|
+
"--no-approve", "--offline", "--help", "--version",
|
|
128
|
+
]);
|
|
129
|
+
const KEEP_VALUE = new Set([
|
|
130
|
+
"--mode", "--provider", "--model", "--api-key", "--system-prompt", "--append-system-prompt",
|
|
131
|
+
"--session-dir", "--models", "--tools", "-t", "--exclude-tools", "-xt", "--thinking",
|
|
132
|
+
"--export", "--extension", "-e", "--skill", "--prompt-template", "--theme", "--use-theme",
|
|
133
|
+
"--tui-mode",
|
|
134
|
+
]);
|
|
135
|
+
export function filterRespawnArgs(args) {
|
|
136
|
+
const out = [];
|
|
137
|
+
for (let i = 0; i < args.length; i++) {
|
|
138
|
+
const a = args[i];
|
|
139
|
+
if (a === "--") break; // everything after is message text
|
|
140
|
+
if (DROP_BOOL.has(a)) continue;
|
|
141
|
+
if (DROP_VALUE.has(a)) {
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (KEEP_VALUE.has(a)) {
|
|
146
|
+
if (i + 1 < args.length) out.push(a, args[++i]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (KEEP_BOOL.has(a)) {
|
|
150
|
+
out.push(a);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (a.startsWith("@")) continue; // file attachment for the old first prompt
|
|
154
|
+
if (a.startsWith("--") && !a.includes("=")) {
|
|
155
|
+
const next = args[i + 1];
|
|
156
|
+
out.push(a);
|
|
157
|
+
if (next !== undefined && !next.startsWith("-") && !next.startsWith("@")) out.push(args[++i]);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (a.startsWith("-")) {
|
|
161
|
+
out.push(a); // --flag=value, or a short bool Pi knows
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// A bare word is an initial message: that was the OLD agent's task.
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── registry + socket paths ─────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export function runDir(home) {
|
|
172
|
+
return path.join(home, "run");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function socketPath(home, token) {
|
|
176
|
+
const id = `${process.pid}-${token.slice(0, 8)}`;
|
|
177
|
+
if (isWin) return `\\\\.\\pipe\\privateer-fresh-${id}`;
|
|
178
|
+
// sun_path is ~104 bytes on macOS; a long PRIVATEER_HOME (a dev home inside a repo)
|
|
179
|
+
// can overflow it, and listen() then fails with a confusing EINVAL.
|
|
180
|
+
const preferred = path.join(runDir(home), `${id}.sock`);
|
|
181
|
+
return Buffer.byteLength(preferred) < 100 ? preferred : path.join(os.tmpdir(), `pv-fresh-${id}.sock`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const alive = (pid) => {
|
|
185
|
+
try {
|
|
186
|
+
process.kill(pid, 0);
|
|
187
|
+
return true;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return e?.code === "EPERM";
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Every supervised terminal still running, oldest first. Stale entries are removed. */
|
|
194
|
+
export function listTerminals(home) {
|
|
195
|
+
const dir = runDir(home);
|
|
196
|
+
let names = [];
|
|
197
|
+
try {
|
|
198
|
+
names = fs.readdirSync(dir).filter((n) => /^\d+\.json$/.test(n));
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
const out = [];
|
|
203
|
+
for (const n of names) {
|
|
204
|
+
const file = path.join(dir, n);
|
|
205
|
+
try {
|
|
206
|
+
const t = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
207
|
+
if (alive(t.pid)) {
|
|
208
|
+
out.push(t);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// A supervisor that died by signal (its terminal closed) never ran its exit
|
|
212
|
+
// cleanup; its entry and socket are cleared by whoever looks next.
|
|
213
|
+
fs.rmSync(file, { force: true });
|
|
214
|
+
if (!isWin && typeof t.socket === "string" && t.socket.endsWith(".sock")) fs.rmSync(t.socket, { force: true });
|
|
215
|
+
} catch {
|
|
216
|
+
/* half-written or unreadable — not ours to act on */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out.sort((a, b) => a.startedAt - b.startedAt);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── client ──────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Ask one supervisor for a fresh agent. Resolves with its reply, rejects on no answer. */
|
|
225
|
+
export function requestFresh(socket, token, timeoutMs = 3000) {
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const conn = net.createConnection(socket);
|
|
228
|
+
let buf = "";
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
conn.destroy();
|
|
231
|
+
reject(new Error("the terminal didn't answer"));
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
conn.on("connect", () => conn.write(JSON.stringify({ op: "fresh", token }) + "\n"));
|
|
234
|
+
conn.on("data", (d) => {
|
|
235
|
+
buf += d;
|
|
236
|
+
const nl = buf.indexOf("\n");
|
|
237
|
+
if (nl < 0) return;
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
conn.end();
|
|
240
|
+
try {
|
|
241
|
+
const reply = JSON.parse(buf.slice(0, nl));
|
|
242
|
+
reply.ok ? resolve(reply) : reject(new Error(reply.error || "refused"));
|
|
243
|
+
} catch {
|
|
244
|
+
reject(new Error("unreadable reply"));
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
conn.on("error", (e) => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
reject(e);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── supervisor ──────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/** Put a terminal a killed TUI left in raw mode / hidden cursor / paste mode back. */
|
|
257
|
+
function resetTerminal() {
|
|
258
|
+
if (!process.stdout.isTTY) return;
|
|
259
|
+
if (!isWin) spawnSync("stty", ["sane"], { stdio: ["inherit", "ignore", "ignore"] });
|
|
260
|
+
// Show cursor, leave the alternate screen, bracketed paste off, pop the kitty keyboard
|
|
261
|
+
// protocol, reset attributes, then clear screen + scrollback so the new agent starts on
|
|
262
|
+
// a blank page rather than under the old conversation.
|
|
263
|
+
process.stdout.write("\x1b[?25h\x1b[?1049l\x1b[?2004l\x1b[<u\x1b[0m\x1b[2J\x1b[3J\x1b[H");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function snapshotTree(rootPid) {
|
|
267
|
+
if (isWin) return null;
|
|
268
|
+
const r = spawnSync("ps", ["-A", "-o", "pid=,ppid=,pgid="], { encoding: "utf8" });
|
|
269
|
+
if (r.status !== 0) return { pids: [rootPid], groups: [] };
|
|
270
|
+
const rows = parsePs(r.stdout);
|
|
271
|
+
const self = rows.find((x) => x.pid === process.pid);
|
|
272
|
+
return collectTree(rows, rootPid, self?.pgid);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function signalTree(tree, sig) {
|
|
276
|
+
for (const g of tree.groups) {
|
|
277
|
+
try {
|
|
278
|
+
process.kill(-g, sig);
|
|
279
|
+
} catch {
|
|
280
|
+
/* group already gone */
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const p of tree.pids) {
|
|
284
|
+
try {
|
|
285
|
+
process.kill(p, sig);
|
|
286
|
+
} catch {
|
|
287
|
+
/* already gone */
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Stop the agent and everything it started. Resolves with how many processes that was. */
|
|
293
|
+
async function reap(child) {
|
|
294
|
+
if (isWin) {
|
|
295
|
+
spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
const tree = snapshotTree(child.pid);
|
|
299
|
+
signalTree(tree, "SIGTERM");
|
|
300
|
+
const deadline = Date.now() + FRESH_GRACE_MS;
|
|
301
|
+
while (Date.now() < deadline && tree.pids.some(alive)) await new Promise((r) => setTimeout(r, 50));
|
|
302
|
+
const survivors = { pids: tree.pids.filter(alive), groups: tree.groups };
|
|
303
|
+
if (survivors.pids.length) signalTree(survivors, "SIGKILL");
|
|
304
|
+
return tree.pids.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run the TUI under supervision. `buildArgs(first)` returns the node argv for each
|
|
309
|
+
* launch — the first uses the user's args as typed, later ones go through
|
|
310
|
+
* filterRespawnArgs — and is called fresh each time so a model the user picked in the old
|
|
311
|
+
* session (saved to settings.json) is what the new one boots on.
|
|
312
|
+
*/
|
|
313
|
+
export function runSupervised(cmd, buildArgs, { home }) {
|
|
314
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
315
|
+
const dir = runDir(home);
|
|
316
|
+
const regFile = path.join(dir, `${process.pid}.json`);
|
|
317
|
+
const sock = socketPath(home, token);
|
|
318
|
+
let child = null;
|
|
319
|
+
let replacing = false;
|
|
320
|
+
let registered = false;
|
|
321
|
+
|
|
322
|
+
const register = () => {
|
|
323
|
+
try {
|
|
324
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
325
|
+
const entry = { pid: process.pid, childPid: child?.pid, socket: sock, token, cwd: process.cwd(), startedAt: Date.now() };
|
|
326
|
+
fs.writeFileSync(regFile, JSON.stringify(entry) + "\n", { mode: 0o600 });
|
|
327
|
+
registered = true;
|
|
328
|
+
} catch {
|
|
329
|
+
/* no registry means `privateer fresh` can't find this terminal; /fresh still works */
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const unregister = () => {
|
|
333
|
+
if (registered) fs.rmSync(regFile, { force: true });
|
|
334
|
+
if (!isWin) fs.rmSync(sock, { force: true });
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const launch = (first) => {
|
|
338
|
+
child = spawn(cmd, buildArgs(first), {
|
|
339
|
+
stdio: "inherit",
|
|
340
|
+
env: { ...process.env, [FRESH_SOCKET_ENV]: sock, [FRESH_TOKEN_ENV]: token },
|
|
341
|
+
});
|
|
342
|
+
child.on("exit", (code, signal) => {
|
|
343
|
+
if (replacing) return;
|
|
344
|
+
server.close();
|
|
345
|
+
unregister();
|
|
346
|
+
if (signal) process.kill(process.pid, signal);
|
|
347
|
+
else process.exit(code ?? 0);
|
|
348
|
+
});
|
|
349
|
+
child.on("error", (e) => {
|
|
350
|
+
console.error(`privateer: failed to launch — ${e.message}`);
|
|
351
|
+
unregister();
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
354
|
+
register();
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const replace = async () => {
|
|
358
|
+
if (replacing || !child) return;
|
|
359
|
+
replacing = true;
|
|
360
|
+
const old = child;
|
|
361
|
+
const exited = new Promise((r) => (old.exitCode !== null || old.signalCode !== null ? r() : old.once("exit", r)));
|
|
362
|
+
const count = await reap(old);
|
|
363
|
+
await exited;
|
|
364
|
+
resetTerminal();
|
|
365
|
+
const extra = count > 1 ? ` and ${count - 1} process${count - 1 === 1 ? "" : "es"} it started` : "";
|
|
366
|
+
process.stdout.write(`\x1b[2m⚓ Fresh agent — the previous one${extra} stopped.\x1b[0m\n`);
|
|
367
|
+
replacing = false;
|
|
368
|
+
launch(false);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const server = net.createServer((conn) => {
|
|
372
|
+
let buf = "";
|
|
373
|
+
conn.on("data", (d) => {
|
|
374
|
+
buf += d;
|
|
375
|
+
if (buf.length > 4096) return conn.destroy();
|
|
376
|
+
const nl = buf.indexOf("\n");
|
|
377
|
+
if (nl < 0) return;
|
|
378
|
+
let msg = null;
|
|
379
|
+
try {
|
|
380
|
+
msg = JSON.parse(buf.slice(0, nl));
|
|
381
|
+
} catch {
|
|
382
|
+
/* falls through to refusal */
|
|
383
|
+
}
|
|
384
|
+
const given = Buffer.from(String(msg?.token ?? ""));
|
|
385
|
+
const want = Buffer.from(token);
|
|
386
|
+
const authed = given.length === want.length && crypto.timingSafeEqual(given, want);
|
|
387
|
+
if (!authed || msg?.op !== "fresh") {
|
|
388
|
+
conn.end(JSON.stringify({ ok: false, error: authed ? "unknown op" : "bad token" }) + "\n");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Answer BEFORE reaping: when /fresh asked, the asker is the process about to die.
|
|
392
|
+
conn.end(JSON.stringify({ ok: true, pid: process.pid }) + "\n");
|
|
393
|
+
void replace();
|
|
394
|
+
});
|
|
395
|
+
conn.on("error", () => {});
|
|
396
|
+
});
|
|
397
|
+
server.on("error", () => {
|
|
398
|
+
/* no control channel — the TUI still runs, only fresh-agent is unavailable */
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
403
|
+
} catch {
|
|
404
|
+
/* socketPath falls back to tmpdir only on length; an unwritable home just fails listen */
|
|
405
|
+
}
|
|
406
|
+
if (!isWin) fs.rmSync(sock, { force: true });
|
|
407
|
+
const oldMask = isWin ? 0 : process.umask(0o077); // socket file created 0600
|
|
408
|
+
server.listen(sock, () => {
|
|
409
|
+
if (!isWin) process.umask(oldMask);
|
|
410
|
+
});
|
|
411
|
+
// listen() binds synchronously enough for the umask window; restore on failure too.
|
|
412
|
+
server.once("error", () => {
|
|
413
|
+
if (!isWin) process.umask(oldMask);
|
|
414
|
+
});
|
|
415
|
+
server.unref();
|
|
416
|
+
|
|
417
|
+
process.on("exit", unregister);
|
|
418
|
+
launch(true);
|
|
419
|
+
}
|
package/bin/privateer-launch.mjs
CHANGED
|
@@ -31,6 +31,7 @@ import { applyPatchesIfNeeded, resolveDep } from "./apply-patches.mjs";
|
|
|
31
31
|
import { routeUpdate } from "./update-route.mjs";
|
|
32
32
|
import { APPROVE_IN_APP_ENV, CLI_SPEND_ENV, PI_SUBCOMMANDS, authProblem, extractHeadlessFlags } from "./headless-flags.mjs";
|
|
33
33
|
import { runToCompletion } from "./run-to-completion.mjs";
|
|
34
|
+
import { filterRespawnArgs, listTerminals, requestFresh, runSupervised } from "./fresh-supervisor.mjs";
|
|
34
35
|
import { configureCompileCache } from "./startup-cache.mjs";
|
|
35
36
|
|
|
36
37
|
const HERE = path.dirname(fileURLToPath(import.meta.url)); // bin/
|
|
@@ -570,6 +571,66 @@ else if (sub === "acp") {
|
|
|
570
571
|
runToCompletion(NODE_BIN, [...nodeArgs, path.join(REPO, "bin", "privateer-acp.mjs"), ...args.slice(1)], { forwardSignals: true });
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
// --- `privateer fresh [n|pid] [--list]` --------------------------------------
|
|
575
|
+
// Swap a running terminal's agent for a brand-new process, reaping everything the old
|
|
576
|
+
// one started. This is the rescue path: an agent that has jammed can't run its own
|
|
577
|
+
// /fresh, so you run this from another terminal. See bin/fresh-supervisor.mjs.
|
|
578
|
+
else if (sub === "fresh") {
|
|
579
|
+
const cmd = process.env.PRIVATEER_CMD || "privateer";
|
|
580
|
+
const rest = args.slice(1);
|
|
581
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
582
|
+
console.log(
|
|
583
|
+
[
|
|
584
|
+
`${cmd} fresh — replace a running terminal's agent with a new one.`,
|
|
585
|
+
"",
|
|
586
|
+
` ${cmd} fresh the only running terminal`,
|
|
587
|
+
` ${cmd} fresh <n|pid> one of several — pick from the list`,
|
|
588
|
+
` ${cmd} fresh --list show running terminals`,
|
|
589
|
+
"",
|
|
590
|
+
"The old agent and every process it started are stopped; the new one starts in",
|
|
591
|
+
"the same terminal with a new session and no link to the old one. Inside a",
|
|
592
|
+
"terminal, /fresh does the same. The old session stays resumable with /resume.",
|
|
593
|
+
].join("\n"),
|
|
594
|
+
);
|
|
595
|
+
process.exit(0);
|
|
596
|
+
}
|
|
597
|
+
const terms = listTerminals(PRIVATEER_HOME);
|
|
598
|
+
const describe = (t, i) =>
|
|
599
|
+
` ${i + 1}. ${t.cwd} (pid ${t.pid}, up ${Math.max(1, Math.round((Date.now() - t.startedAt) / 60000))}m)`;
|
|
600
|
+
if (rest.includes("--list")) {
|
|
601
|
+
console.log(terms.length ? terms.map(describe).join("\n") : "No running Privateer terminals.");
|
|
602
|
+
process.exit(0);
|
|
603
|
+
}
|
|
604
|
+
const pick = rest.find((a) => !a.startsWith("-"));
|
|
605
|
+
let target;
|
|
606
|
+
if (pick) {
|
|
607
|
+
const n = Number(pick);
|
|
608
|
+
target = terms.find((t) => t.pid === n) ?? (Number.isInteger(n) && n >= 1 ? terms[n - 1] : undefined);
|
|
609
|
+
if (!target) {
|
|
610
|
+
console.error(`${cmd} fresh: no running terminal "${pick}" — ${cmd} fresh --list shows them.`);
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
} else if (terms.length === 1) {
|
|
614
|
+
target = terms[0];
|
|
615
|
+
} else if (terms.length === 0) {
|
|
616
|
+
console.error("No running Privateer terminals.");
|
|
617
|
+
process.exit(1);
|
|
618
|
+
} else {
|
|
619
|
+
console.error(`Several Privateer terminals are running — pick one:\n${terms.map(describe).join("\n")}\n\n ${cmd} fresh <n>`);
|
|
620
|
+
process.exit(2);
|
|
621
|
+
}
|
|
622
|
+
requestFresh(target.socket, target.token).then(
|
|
623
|
+
() => {
|
|
624
|
+
console.log(`Fresh agent starting in ${target.cwd}.`);
|
|
625
|
+
process.exit(0);
|
|
626
|
+
},
|
|
627
|
+
(e) => {
|
|
628
|
+
console.error(`${cmd} fresh: ${e.message}`);
|
|
629
|
+
process.exit(1);
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
573
634
|
// --- Pi's own subcommands: `auth`, and the package commands -------------------
|
|
574
635
|
// Pi matches these only as args[0]. The normal launch below prepends --model, -e and
|
|
575
636
|
// --skill, so every one of them used to fall through as a CHAT MESSAGE — `privateer
|
|
@@ -748,19 +809,24 @@ else {
|
|
|
748
809
|
// 3. a saved pick in settings.json → pass NO flag; Pi resolves it itself (and
|
|
749
810
|
// falls back sanely if that model has vanished from the registry)
|
|
750
811
|
// 4. nothing saved (first run / fresh home) → the computed MODEL above
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
812
|
+
//
|
|
813
|
+
// A FUNCTION, called per launch: a fresh agent (bin/fresh-supervisor.mjs) re-reads it,
|
|
814
|
+
// so a model picked in the old session boots the new one instead of being stomped by
|
|
815
|
+
// a first-run --model computed before that pick existed.
|
|
816
|
+
const modelArgsFor = (launchArgs) => {
|
|
817
|
+
const userPassedModel = launchArgs.includes("--model");
|
|
818
|
+
let savedDefault = null;
|
|
819
|
+
try {
|
|
820
|
+
const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
|
|
821
|
+
if (
|
|
822
|
+
typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
|
|
823
|
+
typeof s.defaultModel === "string" && s.defaultModel.trim()
|
|
824
|
+
) {
|
|
825
|
+
savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
|
|
826
|
+
}
|
|
827
|
+
} catch { /* absent/unreadable → no saved pick */ }
|
|
828
|
+
return userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
|
|
829
|
+
};
|
|
764
830
|
|
|
765
831
|
// Dev convenience: load provider keys from the repo's .env if present.
|
|
766
832
|
const nodeArgs = fs.existsSync(ENV_FILE) ? [`--env-file=${ENV_FILE}`] : [];
|
|
@@ -786,7 +852,12 @@ else {
|
|
|
786
852
|
const splash = path.join(HERE, "privateer-splash.mjs");
|
|
787
853
|
if (!isNonInteractive && fs.existsSync(splash)) nodeArgs.push("--import", pathToFileURL(splash).href);
|
|
788
854
|
|
|
789
|
-
runToCompletion
|
|
855
|
+
// Supervised rather than runToCompletion: the launcher stays up so /fresh, a voice
|
|
856
|
+
// command, or `privateer fresh` from another terminal can swap this agent for a new
|
|
857
|
+
// process with the old tree reaped. Print/export/version runs have nothing to swap.
|
|
858
|
+
const tuiArgs = (launchArgs) => [...nodeArgs, CLI, ...modelArgsFor(launchArgs), ...extArgs, ...skillArgs, ...launchArgs];
|
|
859
|
+
if (isNonInteractive) runToCompletion(NODE_BIN, tuiArgs(args));
|
|
860
|
+
else runSupervised(NODE_BIN, (first) => tuiArgs(first ? args : filterRespawnArgs(args)), { home: PRIVATEER_HOME });
|
|
790
861
|
}
|
|
791
862
|
|
|
792
863
|
// --- helpers ---------------------------------------------------------------
|
|
@@ -969,6 +1040,7 @@ function printPrivateerHelp(cmd = process.env.PRIVATEER_CMD || "privateer") {
|
|
|
969
1040
|
` ${cmd} harbor <command> Manage the resident background Harbor daemon (run/install/uninstall/status)`,
|
|
970
1041
|
` ${cmd} verify Check local installation integrity and patch state`,
|
|
971
1042
|
` ${cmd} acp Run as an Agent Client Protocol server (JSON-RPC on stdio)`,
|
|
1043
|
+
` ${cmd} fresh [n] Replace a running terminal's agent with a new one (stops everything it started)`,
|
|
972
1044
|
` ${cmd} install <source> Install extension source and add to settings`,
|
|
973
1045
|
` ${cmd} remove <source> Remove extension source from settings`,
|
|
974
1046
|
` ${cmd} uninstall <source> Alias for remove`,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// /fresh — replace this agent with a brand-new one, in the same terminal.
|
|
2
|
+
//
|
|
3
|
+
// Not /new. Pi's /new starts a new session inside THIS process: whatever has wedged,
|
|
4
|
+
// leaked or been left running (a dev server, a watcher, a subagent) comes along. /fresh
|
|
5
|
+
// asks the launcher above us (bin/fresh-supervisor.mjs) to stop this process and every
|
|
6
|
+
// process it started, then boot a new agent with a new session and nothing linking it
|
|
7
|
+
// to this one. The session file stays on disk, so /resume can still bring it back.
|
|
8
|
+
//
|
|
9
|
+
// It runs immediately, with no confirmation, because nothing is lost that /resume
|
|
10
|
+
// can't bring back — except the processes it reaps, which is the point.
|
|
11
|
+
//
|
|
12
|
+
// Only a terminal started by `privateer` has a supervisor. The desktop app, the harbor,
|
|
13
|
+
// ACP and subagent children don't, and they say so instead of failing quietly. A jammed
|
|
14
|
+
// agent can't run this at all — its event loop is the thing that's stuck — which is
|
|
15
|
+
// what `privateer fresh` from another terminal is for.
|
|
16
|
+
//
|
|
17
|
+
// Voice: extensions/privateer-speak.ts maps a few spoken phrases ("fresh start", …)
|
|
18
|
+
// onto this command.
|
|
19
|
+
|
|
20
|
+
import { FRESH_SOCKET_ENV, FRESH_TOKEN_ENV, requestFresh } from "../bin/fresh-supervisor.mjs";
|
|
21
|
+
|
|
22
|
+
// Read once, then taken out of this process's environment: every bash command and
|
|
23
|
+
// subagent the agent runs inherits process.env, and none of them has any business
|
|
24
|
+
// holding the key that restarts this terminal (and kills whatever it's running).
|
|
25
|
+
// Kept on globalThis rather than in a module variable because /reload and a new
|
|
26
|
+
// session re-run this factory, possibly through a fresh module instance, and by then
|
|
27
|
+
// the environment no longer has it.
|
|
28
|
+
const KEY = Symbol.for("privateer.fresh.launcher");
|
|
29
|
+
function launcherKey(): { socket?: string; token?: string } {
|
|
30
|
+
const g = globalThis as any;
|
|
31
|
+
if (!g[KEY]) {
|
|
32
|
+
g[KEY] = { socket: process.env[FRESH_SOCKET_ENV], token: process.env[FRESH_TOKEN_ENV] };
|
|
33
|
+
delete process.env[FRESH_SOCKET_ENV];
|
|
34
|
+
delete process.env[FRESH_TOKEN_ENV];
|
|
35
|
+
}
|
|
36
|
+
return g[KEY];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function privateerFresh(pi: any): void {
|
|
40
|
+
const { socket, token } = launcherKey();
|
|
41
|
+
|
|
42
|
+
pi.registerCommand?.("fresh", {
|
|
43
|
+
description: "Replace this agent with a new one — stops everything it started, keeps the session for /resume",
|
|
44
|
+
handler: async (_args: string, ctx: any) => {
|
|
45
|
+
const notify = (m: string, kind: "info" | "warning" | "error" = "info") => ctx?.ui?.notify?.(m, kind);
|
|
46
|
+
if (!socket || !token) {
|
|
47
|
+
notify("/fresh needs a terminal started with `privateer` — this session has no launcher to hand over to. /new starts a new session in place.", "warning");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
notify("Starting a fresh agent…");
|
|
51
|
+
try {
|
|
52
|
+
await requestFresh(socket, token);
|
|
53
|
+
// The launcher now stops this process; nothing more to do here.
|
|
54
|
+
} catch (e) {
|
|
55
|
+
notify(`Couldn't reach the launcher (${e instanceof Error ? e.message : String(e)}). /new starts a new session in place.`, "error");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -120,5 +120,16 @@ export default function privateerSpeak(pi: any): void {
|
|
|
120
120
|
// with the generic package keeps two independent setups instead of a fought-over
|
|
121
121
|
// file. globalDir() is read here, not at module load, so PRIVATEER_HOME set around
|
|
122
122
|
// session creation (tests, the daemon) is honoured.
|
|
123
|
-
|
|
123
|
+
//
|
|
124
|
+
// voiceCommands: saying one of these AS THE WHOLE UTTERANCE runs /fresh (see
|
|
125
|
+
// extensions/privateer-fresh.ts) instead of sending the words to the model. Whole
|
|
126
|
+
// utterance only, so "why did the fresh start fail" is still a question.
|
|
127
|
+
makePiSpeakExtension({ configFile: join(globalDir(), "speak.json"), voiceCommands: VOICE_COMMANDS })(pi);
|
|
124
128
|
}
|
|
129
|
+
|
|
130
|
+
const VOICE_COMMANDS: Record<string, string> = {
|
|
131
|
+
"fresh start": "/fresh",
|
|
132
|
+
"start fresh": "/fresh",
|
|
133
|
+
"fresh agent": "/fresh",
|
|
134
|
+
"new agent": "/fresh",
|
|
135
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "privateer-agent",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.50",
|
|
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.3.0",
|
|
90
90
|
"privateer-workflow": "0.1.0",
|
|
91
91
|
"tinfoil": "1.1.11",
|
|
92
92
|
"tsx": "4.23.0",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
{ "entry": "extensions/privateer-desktop.ts", "name": "privateer-desktop", "note": "/desktop — open the Privateer desktop app" },
|
|
23
23
|
{ "entry": "extensions/privateer-hints.ts", "name": "privateer-hints", "note": "rotating tips in the working line + /hints" },
|
|
24
24
|
{ "entry": "extensions/privateer-update.ts", "name": "privateer-update", "note": "tool pack updates in place — banner flag + /update" },
|
|
25
|
+
{ "entry": "extensions/privateer-fresh.ts", "name": "privateer-fresh", "note": "/fresh — replace the agent with a new one and stop everything it started" },
|
|
25
26
|
{ "entry": "extensions/privateer-speak.ts", "name": "privateer-speak", "note": "spoken responses (/speak) + voice input (/talk) — pi-speak + confidential account TTS/STT" },
|
|
26
27
|
{ "entry": "extensions/privateer-web.ts", "name": "privateer-web", "note": "web_search/web_fetch — account search when signed in, else the user's own provider (rpiv-web-tools)" },
|
|
27
28
|
{ "dep": ["@juicesharp/rpiv-ask-user-question", "index.ts"], "name": "rpiv-ask-user-question", "note": "ask_user_question" },
|