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 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
+ }
@@ -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
- const userPassedModel = args.includes("--model");
752
- let savedDefault = null;
753
- try {
754
- const s = JSON.parse(fs.readFileSync(path.join(AGENT_DIR, "settings.json"), "utf8"));
755
- if (
756
- typeof s.defaultProvider === "string" && s.defaultProvider.trim() &&
757
- typeof s.defaultModel === "string" && s.defaultModel.trim()
758
- ) {
759
- savedDefault = `${s.defaultProvider}/${s.defaultModel}`;
760
- }
761
- } catch { /* absent/unreadable → no saved pick */ }
762
- const modelArgs =
763
- userPassedModel || (savedDefault && !process.env.PRIVATEER_MODEL) ? [] : ["--model", MODEL];
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(NODE_BIN, [...nodeArgs, CLI, ...modelArgs, ...extArgs, ...skillArgs, ...args]);
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
- makePiSpeakExtension({ configFile: join(globalDir(), "speak.json") })(pi);
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.49",
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.2.2",
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" },