privateer-agent 0.3.6 → 0.4.1

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.
Files changed (43) hide show
  1. package/bin/privateer-daemon.mjs +30 -0
  2. package/bin/privateer-subagent.mjs +68 -0
  3. package/bin/privateer-tui +19 -0
  4. package/extensions/privateer-brand.ts +65 -24
  5. package/extensions/privateer-gate.ts +290 -3
  6. package/package.json +4 -1
  7. package/src/auth/privateer.ts +45 -6
  8. package/src/channels/bridge.ts +293 -0
  9. package/src/channels/discord.ts +210 -0
  10. package/src/channels/run.ts +384 -0
  11. package/src/channels/slack.ts +176 -0
  12. package/src/channels/status.ts +54 -0
  13. package/src/channels/telegram.ts +139 -0
  14. package/src/channels/types.ts +36 -0
  15. package/src/channels/whatsapp.ts +178 -0
  16. package/src/cli/chat.ts +395 -32
  17. package/src/cli/daemonCli.ts +67 -0
  18. package/src/crypto/accountTrust.ts +113 -0
  19. package/src/crypto/accountVerify.ts +138 -0
  20. package/src/crypto/terminalKey.ts +95 -0
  21. package/src/crypto/terminalUnseal.ts +62 -0
  22. package/src/daemon/index.ts +516 -48
  23. package/src/daemon/service.ts +232 -0
  24. package/src/ext/permissionGate.ts +38 -0
  25. package/src/permissions/classify.ts +49 -5
  26. package/src/providers/account.ts +7 -1
  27. package/src/providers/defaultModel.ts +119 -0
  28. package/src/remote/channelsControl.ts +192 -0
  29. package/src/remote/controlAuth.ts +67 -0
  30. package/src/remote/extensionsControl.ts +140 -0
  31. package/src/remote/liveTaskSession.ts +218 -0
  32. package/src/remote/relayClient.ts +512 -1
  33. package/src/remote/remoteBridge.ts +172 -0
  34. package/src/remote/routinesControl.ts +216 -0
  35. package/src/remote/skillsControl.ts +205 -0
  36. package/src/remote/subagentChannel.ts +261 -0
  37. package/src/remote/subagentRelay.ts +126 -0
  38. package/src/remote/workflowsControl.ts +132 -0
  39. package/src/routines/store.ts +5 -1
  40. package/src/workflows/expr.ts +4 -0
  41. package/src/workflows/runner.ts +8 -0
  42. package/src/workflows/schema.ts +5 -0
  43. package/src/workflows/store.ts +108 -0
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the resident Privateer daemon (routines + app-driven headless task
3
+ // spawns). Mirrors bin/privateer.mjs: load dev keys from the repo .env WITHOUT
4
+ // changing cwd, register tsx so TS resolves regardless of the invocation cwd, then
5
+ // hand off to the daemon CLI dispatcher (which imports ./boot.ts before any Pi code).
6
+ //
7
+ // Invoked two ways: interactively via the bash launcher (`privateer daemon …`), and
8
+ // by the installed launchd/systemd service (`node privateer-daemon.mjs run`).
9
+ import { register } from "tsx/esm/api";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, resolve } from "node:path";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const repo = resolve(here, "..");
15
+
16
+ try {
17
+ process.loadEnvFile(resolve(repo, ".env"));
18
+ } catch {
19
+ /* no .env — rely on the ambient environment / ~/.privateer */
20
+ }
21
+
22
+ // The daemon loads the moat as in-code factories, so its subagent children (routines /
23
+ // task sessions) can't inherit it and `pi` isn't on PATH. Point pi-subagents at our
24
+ // moat-injecting wrapper so those children spawn gated + private with no double-load.
25
+ // Process-global is safe here: the wrapper is stateless (unlike a per-parent channel).
26
+ process.env.PI_SUBAGENT_PI_BINARY ??= resolve(repo, "bin/privateer-subagent.mjs");
27
+
28
+ register();
29
+ const { runDaemonCli } = await import(resolve(repo, "src/cli/daemonCli.ts"));
30
+ await runDaemonCli(process.argv.slice(2));
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // The binary pi-subagents spawns for each subagent child (via PI_SUBAGENT_PI_BINARY)
3
+ // when the PARENT loaded privateer's moat as IN-CODE extension factories (the lean
4
+ // REPL, the daemon, live task sessions) rather than agent-dir discovery.
5
+ //
6
+ // Why a wrapper here (vs the plain cli.js the TUI uses): a subagent child is a fresh
7
+ // `pi` subprocess that can't inherit the parent's in-code factories. It CAN auto-
8
+ // discover agent-dir extensions — but if the parent ALSO loaded those same shims as
9
+ // factories, Pi loads both (resource-loader merges discovered + inline) and the moat
10
+ // double-loads (two gates, two provider registrations). So instead of relying on
11
+ // discovery, this wrapper injects the moat EXPLICITLY as `-e` extensions and passes
12
+ // `--no-extensions` to turn agent-dir discovery OFF. The child then loads exactly:
13
+ // • pi-subagents' own runtime extensions (already present in the argv it built), and
14
+ // • privateer's gate + privacy + account (the three `-e` below),
15
+ // with no discovery, hence no double-load — while pi-subagents' explicit `--extension`
16
+ // args still load (‑‑no‑extensions only disables DISCOVERY, not explicit `-e`).
17
+ //
18
+ // Exported helpers are pure and unit-tested (tests/subagentWrapper.test.ts); the
19
+ // spawn only runs when this file is invoked as a binary.
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { fileURLToPath } from "node:url";
23
+ import { dirname, resolve, join } from "node:path";
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url)); // bin/
26
+ const REPO = resolve(HERE, ".."); // repo root
27
+
28
+ // Absolute paths to privateer's moat extension entry files (the same modules the TUI
29
+ // installs as discovery shims). gate = the permission moat (fail-closed / forwards
30
+ // child approvals to the parent); privacy = ZDR/TEE posture + attestation dispatcher;
31
+ // account = the privateer/* provider so a child can run account models.
32
+ export function moatExtensionPaths(repoRoot = REPO) {
33
+ return [
34
+ join(repoRoot, "extensions", "privateer-gate.ts"),
35
+ join(repoRoot, "extensions", "privateer-privacy.ts"),
36
+ join(repoRoot, "extensions", "privateer-account.ts"),
37
+ ];
38
+ }
39
+
40
+ // The bundled Pi CLI the child actually runs (executable, `#!/usr/bin/env node`).
41
+ export function piCliPath(repoRoot = REPO) {
42
+ return join(repoRoot, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
43
+ }
44
+
45
+ // Given the args pi-subagents built for the child, return the args to run the bundled
46
+ // cli.js with: `--no-extensions` + one `-e <path>` per moat extension, THEN the
47
+ // original args (so the injected flags precede the positional `Task:` prompt).
48
+ export function buildChildArgs(originalArgs, repoRoot = REPO) {
49
+ const inject = ["--no-extensions"];
50
+ for (const p of moatExtensionPaths(repoRoot)) inject.push("-e", p);
51
+ return [...inject, ...originalArgs];
52
+ }
53
+
54
+ // Run only when invoked directly (not when imported by the test).
55
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
56
+ const args = buildChildArgs(process.argv.slice(2));
57
+ const child = spawn(process.execPath, [piCliPath(), ...args], { stdio: "inherit", env: process.env });
58
+ // Propagate the child's exit faithfully so pi-subagents' parent reads the real
59
+ // outcome (a signal re-raises; otherwise exit with the same code).
60
+ child.on("exit", (code, signal) => {
61
+ if (signal) process.kill(process.pid, signal);
62
+ else process.exit(code ?? 0);
63
+ });
64
+ child.on("error", (e) => {
65
+ console.error(`privateer-subagent: failed to spawn pi — ${e.message}`);
66
+ process.exit(1);
67
+ });
68
+ }
package/bin/privateer-tui CHANGED
@@ -38,6 +38,18 @@ pick_node() {
38
38
  echo "node"
39
39
  }
40
40
 
41
+ # `privateer daemon [run|install|uninstall|status]` — the resident background daemon
42
+ # (routines + app-driven headless task spawns). Handled HERE, before the Pi TUI exec
43
+ # and before the moat-shim install (the daemon doesn't need the interactive extension
44
+ # set), so it boots straight into src/daemon via bin/privateer-daemon.mjs.
45
+ if [ "${1:-}" = "daemon" ]; then
46
+ shift
47
+ DAEMON_NODE="$(pick_node)"
48
+ DAEMON_ENV_ARGS=""
49
+ [ -f "$REPO/.env" ] && DAEMON_ENV_ARGS="--env-file=$REPO/.env"
50
+ exec "$DAEMON_NODE" ${DAEMON_ENV_ARGS:+"$DAEMON_ENV_ARGS"} "$REPO/bin/privateer-daemon.mjs" "$@"
51
+ fi
52
+
41
53
  AGENT_DIR="${PRIVATEER_HOME:-$HOME/.privateer}/agent"
42
54
  EXT_DIR="$AGENT_DIR/extensions"
43
55
  mkdir -p "$EXT_DIR"
@@ -70,6 +82,13 @@ shim pi-subagents "$REPO/node_modules/pi-subagents/src/extension/index.ts"
70
82
 
71
83
  CLI="$REPO/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
72
84
  export PI_CODING_AGENT_DIR="$AGENT_DIR"
85
+ # The binary pi-subagents spawns for each subagent child. Without this it falls back
86
+ # to `pi` on PATH (getPiSpawnCommand) — which a Privateer install does NOT provide, so
87
+ # every subagent spawn would fail with ENOENT. Point it at OUR bundled cli.js (it's
88
+ # executable, `#!/usr/bin/env node`): the child then reads this same PI_CODING_AGENT_DIR
89
+ # and DISCOVERS the moat shims above — so subagents run gated + private, no -e injection
90
+ # (hence no double-load). Set only when unset so a power user can override it.
91
+ export PI_SUBAGENT_PI_BINARY="${PI_SUBAGENT_PI_BINARY:-$CLI}"
73
92
  # Suppress Pi's upstream "Update Available — run `pi update`" banner. It's noise on a
74
93
  # Privateer install (our banner is the startup surface), it leaks the "pi" name, and
75
94
  # `pi update` would fight our npm-managed install. PI_SKIP_VERSION_CHECK disables ONLY
@@ -34,35 +34,75 @@ const VERSION: string = (() => {
34
34
  }
35
35
  })();
36
36
 
37
- // ── palette (Privateer "Open Water" ocean-blue brand) ────────────────────────
37
+ // ── palette (Privateer brand) ────────────────────────────────────────────────
38
38
  // 256-color (8-bit), NOT 24-bit truecolor: macOS Terminal.app doesn't support
39
39
  // truecolor and mangles it (the old indigo/cyan came out green). These indices are
40
- // universally supported. Navy (the logo mark) is too dark to read on a dark terminal,
41
- // so we use the app's ocean blues the same "Open Water" gradient as the brand.
40
+ // universally supported. Navy (the logo mark) is too dark to read on a dark terminal, so
41
+ // the whole banner silhouette, wordmark, frame, and accents is painted white: a clean
42
+ // single-color mark, like the logo but legible on dark.
42
43
  const ESC = "\x1b[";
43
44
  const RESET = `${ESC}0m`;
44
45
  const BOLD = `${ESC}1m`;
45
46
  const c = (n: number): string => `${ESC}38;5;${n}m`;
46
- const OCEAN = c(39); // #00afffprimary ocean blue (anchor, wordmark "P")
47
- const OCEAN_LIGHT = c(81); // #5fd7fflight sky accent (wordmark, version, path)
48
- const BORDER = c(32); // #0087d7deeper ocean, the frame
47
+ const OCEAN = c(231); // white (#ffffff)anchor / wordmark "P"
48
+ const OCEAN_LIGHT = c(231); // white (#ffffff) — wordmark, version, path
49
+ const BORDER = c(231); // white (#ffffff) — the frame
49
50
  const DIM = `${ESC}90m`;
50
51
  const GREEN = `${ESC}32m`;
51
52
  const YELLOW = `${ESC}33m`;
52
53
 
53
- // The Privateer mark in ASCII: a padlock (with keyhole) atop an anchor — "bring your
54
- // own model" meets lock-and-key privacy, echoing the app's anchor+padlock logo. Every
55
- // line is the SAME visible width (MARK_W) and centers on column 5 (the shank/keyhole),
56
- // so the text column beside it stays aligned. Keep them equal-width if you edit the art.
57
- const MARK_W = 11;
58
- const ANCHOR = [
59
- " .-. ", // shackle arch
60
- " |___| ", // lock body top (the shackle's base)
61
- " |_t_| ", // lock body + keyhole
62
- " /\\ | /\\ ", // stock — arms flare from the shank (each \\ is one backslash)
63
- " \\ | / ", // arms
64
- " \\_|_/ ", // flukes
54
+ // The Privateer mark: our symbol a padlock (with a keyhole) fused into an anchor,
55
+ // "bring your own model" meets lock-and-key privacy drawn from the app's logo. It's
56
+ // rendered with terminal HALF-BLOCKS, so each text row packs TWO pixel rows: a "▀"
57
+ // whose FOREGROUND paints the top pixel and BACKGROUND the bottom (one pixel "▀"/"▄"
58
+ // on the default bg; none → a plain space). 256-color indices only, same reason as the
59
+ // palette above. Every built line is MARK_W visible cells wide (SGR escapes don't
60
+ // count), so the text column beside it stays aligned. To redraw: edit PIXELS (each char
61
+ // is a PX palette key), keeping every row MARK_W long and the row COUNT even — the
62
+ // builder derives the escapes from that.
63
+ const MARK_W = 12;
64
+ const PX: Record<string, number | null> = {
65
+ ".": null, // transparent — the frame (and the knocked-out keyhole) shows through
66
+ O: 231, // white (#ffffff, top of the 256-color cube) — the silhouette, a clean single-color mark
67
+ };
68
+ // 12 wide; an EVEN number of rows so they pair cleanly into half-block cells. Two blank
69
+ // leading rows give the lock a little headroom without dropping the whole mark too low.
70
+ // A small padlock rides on top as the anchor's ring: a narrow rounded shackle over an
71
+ // 8-wide body that OVERHANGS the shackle on both sides (matching the logo's proportions,
72
+ // where the body is clearly wider than the shackle), with a small centered 2×2 keyhole
73
+ // knocked out of it — reads clearly as a lock without dominating the anchor. Then a short
74
+ // shank → hooked fluke barbs (a 2-tall blade tip that turns up-and-out) → crown → bill.
75
+ const PIXELS = [
76
+ "............", "............",
77
+ "....OOOO....", "...OO..OO...", "...OO..OO...",
78
+ "..OOOOOOOO..", "..OOOOOOOO..",
79
+ "..OOO..OOO..", "..OOO..OOO..",
80
+ "..OOOOOOOO..", "..OOOOOOOO..",
81
+ ".....OO.....", ".....OO.....",
82
+ "O....OO....O", "OO...OO...OO", "OO...OO...OO",
83
+ ".OO..OO..OO.", "..OO.OO.OO..",
84
+ "..OOOOOOOO..", "...OOOOOO...", "....OOOO....", ".....OO.....",
65
85
  ];
86
+ // Build the mark once at load. Each cell resets SGR so a background color can never
87
+ // bleed into the row padding the framer adds after it.
88
+ const MARK: string[] = (() => {
89
+ const rows: string[] = [];
90
+ for (let r = 0; r < PIXELS.length; r += 2) {
91
+ const top = PIXELS[r];
92
+ const bot = PIXELS[r + 1] ?? ".".repeat(MARK_W);
93
+ let line = "";
94
+ for (let x = 0; x < MARK_W; x++) {
95
+ const t = PX[top[x]];
96
+ const bcol = PX[bot[x]];
97
+ if (t == null && bcol == null) line += " ";
98
+ else if (t != null && bcol != null) line += `${ESC}38;5;${t}m${ESC}48;5;${bcol}m▀${RESET}`;
99
+ else if (t != null) line += `${ESC}38;5;${t}m▀${RESET}`;
100
+ else line += `${ESC}38;5;${bcol}m▄${RESET}`;
101
+ }
102
+ rows.push(line);
103
+ }
104
+ return rows;
105
+ })();
66
106
 
67
107
  // Visible width = characters after stripping SGR escapes. Everything we render inside
68
108
  // the box is ASCII or a BMP width-1 symbol, so a plain length is exact here.
@@ -99,7 +139,7 @@ function shortCwd(): string {
99
139
  // - signed in → "connected as <account>"
100
140
  // - signed out AND the current model bills to a Privateer account → it can't run
101
141
  // until they sign in, so say so plainly (warning)
102
- // - signed out on their own key → a quiet tease that /signin adds more
142
+ // - signed out on their own key → a quiet tease that /login adds more
103
143
  function accountLine(modelProvider?: string): string {
104
144
  const u = priv.currentUser();
105
145
  if (u) {
@@ -107,9 +147,9 @@ function accountLine(modelProvider?: string): string {
107
147
  return `${GREEN}connected${DIM} as ${RESET}${OCEAN_LIGHT}${label}${RESET}`;
108
148
  }
109
149
  if (modelProvider === "privateer") {
110
- return `${YELLOW}not signed in · /signin to use this model${RESET}`;
150
+ return `${YELLOW}not signed in · /login to use this model${RESET}`;
111
151
  }
112
- return `${DIM}not signed in · ${OCEAN_LIGHT}/signin${DIM} to connect your account${RESET}`;
152
+ return `${DIM}not signed in · ${OCEAN_LIGHT}/login${DIM} to connect your account${RESET}`;
113
153
  }
114
154
 
115
155
  // Is dotted version `a` newer than `b`? Plain numeric compare of major.minor.patch —
@@ -200,10 +240,11 @@ function renderBanner(width: number, modelProvider?: string): string[] {
200
240
  // Zip the mark and the text column by row. Rows past the mark's height get a blank
201
241
  // gutter of the mark's width, so the text stays in one column throughout.
202
242
  const gap = " ";
203
- const height = Math.max(ANCHOR.length, text.length);
243
+ const height = Math.max(MARK.length, text.length);
204
244
  const rows: string[] = [];
205
245
  for (let i = 0; i < height; i++) {
206
- const left = i < ANCHOR.length ? `${OCEAN}${ANCHOR[i]}${RESET}` : " ".repeat(MARK_W);
246
+ // The mark lines already carry their own per-pixel colors, so we don't wrap them.
247
+ const left = i < MARK.length ? MARK[i] : " ".repeat(MARK_W);
207
248
  rows.push(`${left}${gap}${text[i] ?? ""}`.trimEnd());
208
249
  }
209
250
 
@@ -389,7 +430,7 @@ export default function privateerBrand(pi: any): void {
389
430
  ctx?.ui?.setTitle?.("Privateer");
390
431
  refresh(ctx);
391
432
  // No startup notify here: the banner's account line already surfaces the
392
- // "not signed in · /signin" prompt, so a second line would just be noise.
433
+ // "not signed in · /login" prompt, so a second line would just be noise.
393
434
  });
394
435
 
395
436
  // Keep the header's account line in sync with the picked model (the "this model
@@ -13,10 +13,21 @@
13
13
  import { makePermissionGate, defaultLocalAsk } from "../src/ext/permissionGate.ts";
14
14
  import { createEngineEventAdapter } from "../src/bridge/engineAdapter.ts";
15
15
  import { RemoteBridge } from "../src/remote/remoteBridge.ts";
16
+ import {
17
+ isSubagentChild,
18
+ inheritedChannelDir,
19
+ makeChildGateAsk,
20
+ startParentApprovalRelay,
21
+ } from "../src/remote/subagentRelay.ts";
16
22
  import { RelayClient } from "../src/remote/relayClient.ts";
17
23
  import { makeSendFileTool } from "../src/tools/sendFile.ts";
18
24
  import { makeSaveAttachmentTool } from "../src/tools/saveAttachment.ts";
19
25
  import { AttachmentStore, type StoredAttachment } from "../src/util/attachmentStore.ts";
26
+ import { makeExtensionsControl } from "../src/remote/extensionsControl.ts";
27
+ import { makeSkillsControl } from "../src/remote/skillsControl.ts";
28
+ import { agentDir } from "../src/config/paths.ts";
29
+ import { agentVersion } from "../src/config/version.ts";
30
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
20
31
  import * as priv from "../src/auth/privateer.ts";
21
32
  import type { PermissionMode } from "../src/config/permissionMode.ts";
22
33
 
@@ -27,8 +38,183 @@ let mode: PermissionMode = MODES.includes(process.env.PRIVATEER_MODE as Permissi
27
38
  const allowlist: string[] = [];
28
39
  const allowedOutsideRoots: string[] = [];
29
40
 
41
+ // A turn driven from the app is in flight. Guards the remote onPrompt path against a
42
+ // SECOND prompt arriving while Pi is still processing — which throws "Agent is already
43
+ // processing" and wedges the session. This happens in normal use when the app drops
44
+ // (backgrounded → socket suspended) and re-sends its prompt on reconnect. Mirrors the
45
+ // REPL's `turnActive` guard. Set on a successful sendUserMessage, cleared on agent_end.
46
+ let remoteTurnActive = false;
47
+
30
48
  let piRef: any = null;
31
49
  let relay: any = null;
50
+ // Pi-extension manager for the app's extensions screen. Built lazily on first use
51
+ // with a fresh SettingsManager (the ExtensionAPI exposes no package/settings manager),
52
+ // reading the same ~/.privateer/agent/settings.json Pi loads from.
53
+ let extensions: ReturnType<typeof makeExtensionsControl> | null = null;
54
+ function extControl(): ReturnType<typeof makeExtensionsControl> {
55
+ if (!extensions) {
56
+ const cwd = process.cwd();
57
+ extensions = makeExtensionsControl({ cwd, agentDir: agentDir(), settingsManager: SettingsManager.create(cwd, agentDir()) });
58
+ }
59
+ return extensions;
60
+ }
61
+
62
+ // Run an extensions add/remove for the app and relay progress → result. The persist
63
+ // is immediate but the extension only loads on the next terminal launch, so the final
64
+ // frame flags needsRestart. (A live ctx.reload() is only reachable from the local
65
+ // /extensions command handler — not from a relay frame — see registerCommand below.)
66
+ async function runExtMutation(kind: "add" | "remove", source: string): Promise<void> {
67
+ const ext = extControl();
68
+ ext.setProgress((ev) =>
69
+ relay?.sendExtensions({
70
+ installed: ext.listInstalled(),
71
+ busy: ev.type !== "complete" && ev.type !== "error",
72
+ message: ev.message,
73
+ }),
74
+ );
75
+ try {
76
+ const res = kind === "add" ? await ext.add(source) : await ext.remove(source);
77
+ relay?.sendExtensions({
78
+ installed: ext.listInstalled(),
79
+ message: res.ok
80
+ ? `${kind === "add" ? "Added" : "Removed"} ${source} — restart the terminal to activate.`
81
+ : res.message,
82
+ needsRestart: res.ok,
83
+ });
84
+ } finally {
85
+ ext.setProgress(undefined);
86
+ }
87
+ }
88
+
89
+ // Skills manager for the app's skills screen. Built lazily like extControl(), with a
90
+ // fresh SettingsManager reading the same ~/.privateer/agent/settings.json Pi loads.
91
+ let skills: ReturnType<typeof makeSkillsControl> | null = null;
92
+ function skillControl(): ReturnType<typeof makeSkillsControl> {
93
+ if (!skills) {
94
+ const cwd = process.cwd();
95
+ skills = makeSkillsControl({ cwd, agentDir: agentDir(), settingsManager: SettingsManager.create(cwd, agentDir()) });
96
+ }
97
+ return skills;
98
+ }
99
+
100
+ // Run a skills create/delete/toggle for the app and relay the fresh list + result.
101
+ // The write is immediate but only reaches the model's <available_skills> on the next
102
+ // launch (needsRestart); Run-now via /skill:name works without a restart.
103
+ async function runSkillMutation(op: () => Promise<{ ok: boolean; message?: string }>, verb: string): Promise<void> {
104
+ const sk = skillControl();
105
+ const res = await op();
106
+ relay?.sendSkills({
107
+ items: sk.listSkills(),
108
+ message: res.ok ? `${verb} — restart the terminal to update the model's skill list.` : res.message,
109
+ needsRestart: res.ok,
110
+ });
111
+ }
112
+
113
+ // ── app-driven model switching (parity with the REPL's /model picker) ──────────
114
+ // The TUI's own /model command isn't reachable over the relay, so we reproduce it:
115
+ // the model registry + selected spec are captured from session_start / model_select,
116
+ // and currentSpec ("provider/id") follows both app- and locally-driven switches so
117
+ // the app's banner + picker always reflect what's actually selected.
118
+ let modelReg: any = null;
119
+ let currentSpec = "";
120
+
121
+ function modelSpec(m: any): string {
122
+ return m ? `${m.provider}/${m.id}` : "";
123
+ }
124
+
125
+ // This machine's real model catalog as sorted "provider/id" specs — the same list
126
+ // the app's picker draws from (relayed on demand via /model, never pushed).
127
+ function availableModelSpecs(): string[] {
128
+ const all: any[] = modelReg?.getAvailable ? modelReg.getAvailable() : [];
129
+ return all.map(modelSpec).sort();
130
+ }
131
+
132
+ // Switch the live TUI model in place via Pi's setModel, then push context + a notice
133
+ // so the app's banner and feed follow. setModel returns false when no API key is
134
+ // configured for the target provider.
135
+ async function switchModelRemote(spec: string): Promise<void> {
136
+ const sp = spec.trim();
137
+ const at = sp.indexOf("/");
138
+ if (at < 0) { relay?.sendNotice("Usage: /model provider/id"); return; }
139
+ const p = sp.slice(0, at), id = sp.slice(at + 1);
140
+ const model = modelReg?.find?.(p, id);
141
+ if (!model) { relay?.sendNotice(`Model ${sp} not found — try /models.`); return; }
142
+ try {
143
+ const ok = await piRef?.setModel?.(model);
144
+ if (ok === false) { relay?.sendNotice(`No API key for ${p} — can't switch to ${sp}.`); return; }
145
+ currentSpec = sp;
146
+ relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows
147
+ relay?.sendNotice(`model → ${sp}`);
148
+ } catch (e) {
149
+ relay?.sendNotice(`Couldn't switch model: ${(e as Error).message}`);
150
+ }
151
+ }
152
+
153
+ // The app /model picker: relay this machine's catalog as a selection prompt and
154
+ // switch to the driver's choice. Mirrors the REPL's pickModelRemote.
155
+ async function pickModelRemote(filter: string): Promise<void> {
156
+ const specs = availableModelSpecs().filter((sp) => !filter || sp.toLowerCase().includes(filter));
157
+ const choice = await bridge.selectRemote({
158
+ title: "Choose a model",
159
+ options: specs.map((sp) => ({ value: sp, label: sp })),
160
+ current: currentSpec,
161
+ });
162
+ if (choice) await switchModelRemote(choice);
163
+ }
164
+
165
+ // Dispatch an app-composer slash command. The model/mode pickers are handled here
166
+ // (the TUI's native /model can't be reached over the relay); anything else is handed
167
+ // to Pi as a user message so extension/skill commands still run remotely — mirrors
168
+ // the REPL's runCommand fall-through.
169
+ async function runRemoteCommand(text: string): Promise<void> {
170
+ const line = text.trim();
171
+ if (line.startsWith("/model ")) { await switchModelRemote(line.slice(7)); return; }
172
+ if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
173
+ const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
174
+ await pickModelRemote(filter);
175
+ return;
176
+ }
177
+ if (line.startsWith("/mode ")) {
178
+ const m = line.slice(6).trim() as PermissionMode;
179
+ if (MODES.includes(m)) { mode = m; relay?.sendNotice(`mode → ${mode}`); }
180
+ else relay?.sendNotice(`unknown mode "${m}" — use ${MODES.join(" | ")}`);
181
+ return;
182
+ }
183
+ if (line === "/mode") {
184
+ const choice = await bridge.selectRemote({
185
+ title: "Permission mode",
186
+ options: MODES.map((v) => ({ value: v, label: v })),
187
+ current: mode,
188
+ });
189
+ if (choice && MODES.includes(choice as PermissionMode)) { mode = choice as PermissionMode; relay?.sendNotice(`mode → ${mode}`); }
190
+ return;
191
+ }
192
+ piRef?.sendUserMessage?.(line); // fall through: let Pi run it (or treat as a prompt)
193
+ }
194
+
195
+ // The slash commands to advertise to the app's composer: our built-in pickers plus
196
+ // whatever Pi has registered (extension/skill/template commands), deduped. Pushed on
197
+ // controller attach. NON-PII: command names + descriptions only.
198
+ function advertiseCommands(): { name: string; description?: string }[] {
199
+ const builtins = [
200
+ { name: "/model", description: "Switch the model" },
201
+ { name: "/models", description: "List available models" },
202
+ { name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
203
+ ];
204
+ let ext: { name: string; description?: string }[] = [];
205
+ try {
206
+ const cmds = piRef?.getCommands?.() ?? [];
207
+ ext = cmds
208
+ .map((c: any) => {
209
+ const raw = c?.invocationName ?? c?.name ?? c?.command;
210
+ if (!raw) return null;
211
+ return { name: String(raw).startsWith("/") ? String(raw) : `/${raw}`, description: c?.description };
212
+ })
213
+ .filter(Boolean);
214
+ } catch { /* no commands registered yet */ }
215
+ const seen = new Set(builtins.map((c) => c.name));
216
+ return [...builtins, ...ext.filter((c: any) => !seen.has(c.name))];
217
+ }
32
218
 
33
219
  // Persistent footer indicator for remote access. When the relay is up, the footer
34
220
  // shows a GREEN "⟿ remote access" line so it's always obvious this terminal can be
@@ -75,6 +261,13 @@ let sinceLastPrompt: StoredAttachment[] = [];
75
261
 
76
262
  const bridge = new RemoteBridge({
77
263
  onPrompt: (text) => {
264
+ // Drop a prompt that arrives while a driven turn is already running (e.g. the app
265
+ // re-sending after a reconnect) — sendUserMessage would otherwise throw "Agent is
266
+ // already processing" and wedge the session. Tell the app why, don't crash.
267
+ if (remoteTurnActive) {
268
+ relay?.sendNotice("busy — a turn is already running; wait for it to finish.");
269
+ return;
270
+ }
78
271
  // Fold any files the app sent since the last prompt into a reference note so the
79
272
  // model knows they exist and can save_attachment them.
80
273
  const atts = sinceLastPrompt;
@@ -83,16 +276,39 @@ const bridge = new RemoteBridge({
83
276
  ? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
84
277
  `Use the save_attachment tool with the ref number to write one to disk.]`
85
278
  : "";
86
- piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
279
+ try {
280
+ piRef?.sendUserMessage?.(text + note); // drive a turn in Pi's TUI
281
+ remoteTurnActive = true; // cleared on agent_end
282
+ } catch (e) {
283
+ // A synchronous "already processing" (or any send failure) must not wedge the
284
+ // bridge — surface it and stay idle so the next prompt still works.
285
+ relay?.sendNotice(`couldn't start turn: ${(e as Error).message}`);
286
+ }
87
287
  },
88
288
  onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
89
289
  // The app asked to end remote access from its side — stop the relay locally too so
90
290
  // the terminal doesn't keep reconnecting, and clear the green indicator.
91
291
  onTerminate: () => disableRemote(),
292
+ // The account signed this terminal out server-side (revoked from the app's Linked
293
+ // Devices). Unlike onTerminate, this wipes the machine login too: drop the relay,
294
+ // then tear down the session. handleServerRevoke fires onSessionExpired, which the
295
+ // brand extension handles (drops Pi's persisted account credential, refreshes the
296
+ // banner, and notifies "your session was signed out — run /signin").
297
+ onRevoked: () => {
298
+ disableRemote();
299
+ priv.handleServerRevoke();
300
+ },
301
+ // A slash command typed in the app composer (e.g. /model) — dispatch it through the
302
+ // same picker flow the REPL uses. Feedback returns as notice/select_request/context.
303
+ onCommand: (text) => void runRemoteCommand(text),
92
304
  onControllerAttached: () => {
93
- // A controller reached us → the socket is up and driving: go green.
305
+ // A controller reached us → the socket is up and driving: go green. Resync the
306
+ // snapshot, push live context (model + version) so the app banner reflects this
307
+ // terminal, and advertise the slash commands for the composer's autocomplete.
94
308
  setRemoteState("connected");
95
309
  relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
310
+ relay?.sendContext({ model: currentSpec, version: agentVersion() });
311
+ relay?.sendCommands(advertiseCommands());
96
312
  },
97
313
  onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
98
314
  // Drive the indicator from the relay's own status stream: "connected" → green;
@@ -102,15 +318,36 @@ const bridge = new RemoteBridge({
102
318
  if (/disconnect|reconnect|retry|couldn't|could not/i.test(text)) setRemoteState("connecting");
103
319
  else if (/connected/i.test(text)) setRemoteState("connected");
104
320
  },
321
+ // The app's extensions manager: list the user's installed Pi extensions (the moat
322
+ // is excluded), or add/remove one. See runExtMutation for the progress/restart flow.
323
+ onExtensionsList: () => relay?.sendExtensions({ installed: extControl().listInstalled() }),
324
+ onExtensionsAdd: (source) => void runExtMutation("add", source),
325
+ onExtensionsRemove: (source) => void runExtMutation("remove", source),
326
+ // The app's skills manager: list the terminal's skills, or create/delete/toggle a
327
+ // user one. See runSkillMutation for the restart flow; Run-now is a /skill:name
328
+ // command frame handled by Pi, not here.
329
+ onSkillsList: () => relay?.sendSkills({ items: skillControl().listSkills() }),
330
+ onSkillCreate: (skill) => void runSkillMutation(() => skillControl().createSkill(skill), "Saved"),
331
+ onSkillDelete: (name) => void runSkillMutation(() => skillControl().deleteSkill(name), "Deleted"),
332
+ onSkillSetEnabled: (name, enabled) => void runSkillMutation(() => skillControl().setEnabled(name, enabled), enabled ? "Enabled" : "Disabled"),
105
333
  });
106
334
 
335
+ // Inside a subagent child (headless `pi`, stdin ignored), a gated action can't be
336
+ // approved locally — decideAuto still forces dangerous shell / destructive / secret-
337
+ // exfil to "ask", which would otherwise fail-closed to deny. If the root parent wired
338
+ // an approval channel (env-inherited), forward those asks to it so they reach the app;
339
+ // otherwise keep the fail-closed defaultLocalAsk (headless deny). A top-level TUI keeps
340
+ // its own interactive/remote gate.
341
+ const childChannel = isSubagentChild() ? inheritedChannelDir() : undefined;
342
+ const localAsk = childChannel ? makeChildGateAsk(childChannel) : defaultLocalAsk;
343
+
107
344
  const gate = makePermissionGate({
108
345
  getMode: () => mode,
109
346
  setMode: (m) => (mode = m),
110
347
  allowlist,
111
348
  allowedOutsideRoots,
112
349
  cwd: process.cwd(),
113
- localAsk: defaultLocalAsk,
350
+ localAsk,
114
351
  getRemote: bridge.getRemote,
115
352
  getNoQuarter: bridge.getNoQuarter,
116
353
  remoteAsk: bridge.remoteAsk,
@@ -120,6 +357,14 @@ export default function privateerControl(pi: any): void {
120
357
  piRef = pi;
121
358
  gate(pi); // tool_call (block/allow) + tool_result (redact)
122
359
 
360
+ // Top-level session: watch the subagent approval channel and relay each child's
361
+ // gated action to the app over this session's bridge. The bridge fails closed while
362
+ // no controller is attached, so an undriven terminal denies a subagent's gated
363
+ // action rather than auto-approving it. A subagent child never watches (it forwards).
364
+ if (!isSubagentChild()) {
365
+ startParentApprovalRelay(bridge, { onError: () => { /* best-effort; a poll error must not crash the turn */ } });
366
+ }
367
+
123
368
  // File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
124
369
  // save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
125
370
  // live here because they share the RemoteBridge / its attachment stream.
@@ -144,12 +389,25 @@ export default function privateerControl(pi: any): void {
144
389
  // the footer indicator (they fire outside any command's ctx). Re-render in case
145
390
  // remote access was already on when the session (re)started.
146
391
  if (ctx?.ui) uiRef = ctx.ui;
392
+ // Capture the model registry + launch model so the app's /model picker has this
393
+ // machine's real catalog and the banner shows the current spec from the start.
394
+ if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
395
+ if (!currentSpec && ctx?.model) currentSpec = modelSpec(ctx.model);
147
396
  refreshRemoteStatus();
148
397
  if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
149
398
  mode = "bypass";
150
399
  }
151
400
  });
152
401
 
402
+ // Follow local model switches too (the user picking a model in the TUI): keep
403
+ // currentSpec current and push context so a driving app's banner stays in sync.
404
+ pi.on("model_select", (ev: any) => {
405
+ if (ev?.model) {
406
+ currentSpec = modelSpec(ev.model);
407
+ relay?.sendContext({ model: currentSpec, version: agentVersion() });
408
+ }
409
+ });
410
+
153
411
  // Forward turn events to the app. The relay only sends when a controller is
154
412
  // attached, so this is safe on every turn (local or remote).
155
413
  const adapter = createEngineEventAdapter();
@@ -165,6 +423,7 @@ export default function privateerControl(pi: any): void {
165
423
  pi.on("agent_end", (ev: any) => {
166
424
  fwd(ev);
167
425
  bridge.settleTurn();
426
+ remoteTurnActive = false; // turn finished → the next app prompt may start one
168
427
  });
169
428
 
170
429
  pi.registerCommand?.("mode", {
@@ -177,6 +436,34 @@ export default function privateerControl(pi: any): void {
177
436
  },
178
437
  });
179
438
 
439
+ // Local extension management. Mirrors what the app's extensions screen does over
440
+ // the relay, but here we CAN hot-activate: ctx.reload() rebuilds the live runner,
441
+ // so a just-added/removed extension takes effect without relaunching (a luxury the
442
+ // relay path lacks — no command ctx there). Usage: /extensions [add|remove <src>].
443
+ pi.registerCommand?.("extensions", {
444
+ description: "Manage installed Pi extensions: /extensions [add <npm:pkg> | remove <npm:pkg>]",
445
+ handler: async (args: string, ctx: any) => {
446
+ const raw = String(args ?? "").trim();
447
+ const [verb, ...rest] = raw.split(/\s+/);
448
+ const source = rest.join(" ").trim();
449
+ const ext = extControl();
450
+ if (verb === "add" || verb === "remove") {
451
+ if (!source) return ctx.ui?.notify?.(`Usage: /extensions ${verb} <npm:package>`, "warning");
452
+ const res = verb === "add" ? await ext.add(source) : await ext.remove(source);
453
+ if (!res.ok) return ctx.ui?.notify?.(res.message ?? `Couldn't ${verb} ${source}`, "warning");
454
+ await ctx.reload?.(); // hot-activate: rebuild the live extension runner
455
+ // Keep the app's screen in sync if it's attached.
456
+ relay?.sendExtensions({ installed: ext.listInstalled() });
457
+ return ctx.ui?.notify?.(`${verb === "add" ? "Added" : "Removed"} ${source}`, "info");
458
+ }
459
+ const installed = ext.listInstalled();
460
+ ctx.ui?.notify?.(
461
+ installed.length ? `Installed extensions:\n${installed.map((e) => ` ${e.source}`).join("\n")}` : "No extensions installed. Add them from the Privateer app or /extensions add <npm:pkg>.",
462
+ "info",
463
+ );
464
+ },
465
+ });
466
+
180
467
  pi.registerCommand?.("remote-access", {
181
468
  description: "Drive this terminal from the Privateer app: /remote-access on | off",
182
469
  handler: async (args: string, ctx: any) => {