privateer-agent 0.12.47 → 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
@@ -507,6 +507,30 @@ Honest caveat for Buzz: **Buzz currently auto-approves permission prompts**, so
507
507
  the tool ceiling *is* the control — which is exactly why the default is read-only. Full
508
508
  setup, config, and limitations: [`docs/acp.md`](docs/acp.md).
509
509
 
510
+ Driving Privateer from **your own program or agent**? ACP is how that program gets asked:
511
+ every approval arrives as a `session/request_permission` call it answers. The wire format,
512
+ option ids and a minimal client are in [`docs/acp.md`](docs/acp.md#driving-privateer-from-another-agent).
513
+
514
+ ### One-shot runs (`-p`) and spending
515
+
516
+ A `privateer -p "…"` run has no screen, so anything that needs approval is **denied**. That
517
+ includes every billed media tool, and the run says so when it starts. Two flags change it,
518
+ and both apply to that one invocation only. Nothing is saved, and an exported environment
519
+ variable can't turn either on.
520
+
521
+ ```bash
522
+ # pre-approve named billed tools, capped by calls and/or estimated dollars
523
+ privateer -p --allow-spend generate_video --max-calls 1 --max-spend 1.00 "make a 6s intro clip"
524
+
525
+ # or ask on your phone: approvals go to the Privateer app, denied if unanswered in time
526
+ privateer -p --approve-in-app --approval-timeout 300 "make a 6s intro clip"
527
+ ```
528
+
529
+ `--max-spend` is checked before each call against the server's own estimate for that exact
530
+ call. `media_capabilities` shows the same prices. A call that can't be priced is refused
531
+ under a dollar cap rather than let through. `--approve-in-app` reaches the app while it's
532
+ open or running in the background.
533
+
510
534
  ## Connectors — MCP
511
535
 
512
536
  Privateer is an **MCP client**. Point it at a [Model Context Protocol](https://modelcontextprotocol.io)
@@ -664,6 +688,8 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
664
688
  | `/remote-access` | link this terminal to the app and allow it to drive (off by default) |
665
689
  | `/connect` · `/mcp` | add, enable, or remove MCP connectors / see what actually connected |
666
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 |
667
693
  | `/extensions` | list loaded Pi extensions |
668
694
  | `/web-tools` | point `web_search`/`web_fetch` at a search provider of your own (signed in, they already work on your account) |
669
695
  | `/init` | scaffold a starter `PRIVATEER.md` in this directory |
@@ -671,8 +697,13 @@ drop your own into `~/.privateer/agent/extensions/` and it loads the same way, g
671
697
  | `/update` · `/privateer` | update to the latest release / Privateer status and posture |
672
698
 
673
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),
674
702
  `privateer acp` (serve the agent to an ACP host like Buzz or Zed — see
675
- [`docs/acp.md`](docs/acp.md)), `privateer --no-quarter`, `privateer --version`.
703
+ [`docs/acp.md`](docs/acp.md)), `privateer auth status` (is this machine signed in?),
704
+ `privateer -p … [--allow-spend …] [--approve-in-app]` (see
705
+ [one-shot runs](#one-shot-runs--p-and-spending)), `privateer --no-quarter`, `privateer --version`.
706
+ An unknown subcommand of `auth` is an error. It is never sent to the model as a prompt.
676
707
 
677
708
  ## Develop
678
709
 
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ // Types for bin/headless-flags.mjs. Same reason as update-route.d.mts: the
2
+ // implementation is plain .mjs because bin/ runs under a bare `node`, but the tests
3
+ // that pin the grammar are TypeScript.
4
+
5
+ export const BILLED_TOOLS: string[];
6
+ export const CLI_SPEND_ENV: string;
7
+ export const APPROVE_IN_APP_ENV: string;
8
+ export const PI_SUBCOMMANDS: string[];
9
+
10
+ export interface CliSpendGrant {
11
+ tools: string[];
12
+ maxCalls?: number;
13
+ maxSpendUsd?: number;
14
+ }
15
+
16
+ export function isHeadlessRun(args: string[]): boolean;
17
+
18
+ /** Strips the headless flags out of `args` in place. */
19
+ export function extractHeadlessFlags(args: string[]): {
20
+ spend?: CliSpendGrant;
21
+ approveInAppMs?: number;
22
+ error?: string;
23
+ };
24
+
25
+ export function authProblem(args: string[], cmd?: string): string | null;