privateer-agent 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) 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 +61 -20
  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 +383 -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 +389 -30
  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 +511 -46
  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/remote/channelsControl.ts +192 -0
  27. package/src/remote/controlAuth.ts +67 -0
  28. package/src/remote/extensionsControl.ts +140 -0
  29. package/src/remote/liveTaskSession.ts +218 -0
  30. package/src/remote/relayClient.ts +512 -1
  31. package/src/remote/remoteBridge.ts +172 -0
  32. package/src/remote/routinesControl.ts +216 -0
  33. package/src/remote/skillsControl.ts +205 -0
  34. package/src/remote/subagentChannel.ts +261 -0
  35. package/src/remote/subagentRelay.ts +126 -0
  36. package/src/remote/workflowsControl.ts +132 -0
  37. package/src/routines/store.ts +5 -1
  38. package/src/workflows/expr.ts +4 -0
  39. package/src/workflows/runner.ts +8 -0
  40. package/src/workflows/schema.ts +5 -0
  41. package/src/workflows/store.ts +108 -0
package/src/cli/chat.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // e.g. tinfoil/llama3-3-70b to watch TEE posture go green.
9
9
 
10
10
  import "../boot.ts"; // env + attestation dispatcher, before any Pi import
11
+ import { fileURLToPath } from "node:url"; // builtin, safe pre-boot
11
12
  import type { GateController } from "../ext/permissionGate.ts"; // type-only → erased, safe pre-boot
12
13
 
13
14
  const RESET = "\x1b[0m", DIM = "\x1b[2m", CYAN = "\x1b[36m", YELLOW = "\x1b[33m", RED = "\x1b[31m", GREEN = "\x1b[32m";
@@ -20,11 +21,15 @@ async function main() {
20
21
  SessionManager,
21
22
  } = await import("@earendil-works/pi-coding-agent");
22
23
  const { createEngineEventAdapter } = await import("../bridge/engineAdapter.ts");
23
- const { makePermissionGate } = await import("../ext/permissionGate.ts");
24
+ const { makePermissionGate, isRemoteUnsafeTool } = await import("../ext/permissionGate.ts");
24
25
  const { makePiPrivacyExtension, verifyModelPosture, TIERS } = await import("pi-privacy");
25
26
  const { agentDir } = await import("../config/paths.ts");
26
27
  const { RemoteBridge } = await import("../remote/remoteBridge.ts");
28
+ const { startParentApprovalRelay } = await import("../remote/subagentRelay.ts");
27
29
  const { RelayClient } = await import("../remote/relayClient.ts");
30
+ const { makeExtensionsControl } = await import("../remote/extensionsControl.ts");
31
+ const { makeSkillsControl } = await import("../remote/skillsControl.ts");
32
+ const { authorizeControl } = await import("../remote/controlAuth.ts");
28
33
  const priv = await import("../auth/privateer.ts");
29
34
  const { makeAccountProvider, accountPosture } = await import("../providers/account.ts");
30
35
  const { agentVersion } = await import("../config/version.ts");
@@ -34,20 +39,63 @@ async function main() {
34
39
  const provider = spec.slice(0, slash);
35
40
  const modelId = spec.slice(slash + 1);
36
41
  const cwd = process.cwd();
42
+ // Point pi-subagents at our moat-injecting wrapper (unless overridden). This REPL
43
+ // loads the gate/privacy/account as in-code factories, which a subagent child can't
44
+ // inherit; the wrapper injects them explicitly (‑e) with discovery off, so children
45
+ // run gated + private with no parent double-load. Also fixes the plain ENOENT: `pi`
46
+ // isn't on PATH, so without this every subagent spawn would fail. See bin/privateer-
47
+ // subagent.mjs. Absolute path, resolved relative to this module (src/cli/chat.ts).
48
+ process.env.PI_SUBAGENT_PI_BINARY ??= fileURLToPath(new URL("../../bin/privateer-subagent.mjs", import.meta.url));
49
+ // The live model spec ("provider/id"). Starts at the launch model and follows
50
+ // /model switches, so the app banner + picker reflect what's actually selected.
51
+ let currentSpec = spec;
37
52
 
38
53
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
39
54
  let closed = false;
40
55
  rl.on("close", () => (closed = true));
56
+ // Local terminal output is coalesced and prompt-aware. Two stutter sources it kills:
57
+ // 1. Streaming a turn emits one delta per token; thousands of tiny stdout writes
58
+ // visibly stutter a TTY when a turn makes a lot of changes. We batch each burst
59
+ // into ~one write per frame (16 ms) instead.
60
+ // 2. A mid-turn prompt (approval / input) awaits the user via rl.question. Output
61
+ // that streams in while it's pending prints INTO the question line, and readline
62
+ // redraws that line on every keystroke — the "action required" flicker. While a
63
+ // mid-turn prompt is pending we HOLD streamed output and flush it once they
64
+ // answer, so the question line stays clean.
65
+ // Only the local path is batched; the relay path (bridge.forwardEvent) already does
66
+ // its own coalescing (TEXT_FLUSH_MS) and is untouched.
67
+ let outBuf = "";
68
+ let outTimer: ReturnType<typeof setTimeout> | undefined;
69
+ let holdDepth = 0; // >0 while a mid-turn prompt is awaiting the user
70
+ const flushOut = (): void => {
71
+ if (outTimer) { clearTimeout(outTimer); outTimer = undefined; }
72
+ if (outBuf) { process.stdout.write(outBuf); outBuf = ""; }
73
+ };
74
+ const out = (s: string): void => {
75
+ outBuf += s;
76
+ if (holdDepth > 0) return; // held until the prompt resolves
77
+ if (!outTimer) outTimer = setTimeout(flushOut, 16);
78
+ };
79
+
41
80
  // Resolve to /quit if the input stream ends (EOF / Ctrl-D / piped input), incl.
42
81
  // if it closes while a question is pending, so we never throw USE_AFTER_CLOSE.
43
82
  const ask = (q: string): Promise<string> =>
44
83
  new Promise((res) => {
45
84
  if (closed) return res("/quit");
46
- const onClose = () => res("/quit");
85
+ flushOut(); // land buffered output ABOVE the prompt line
86
+ // Hold streamed output only for a MID-TURN prompt. The idle top-level `›` must
87
+ // still show a remote-driven turn streaming live, so don't hold when idle.
88
+ const hold = turnActive;
89
+ if (hold) holdDepth++;
90
+ const settle = (v: string): void => {
91
+ if (hold && holdDepth > 0 && --holdDepth === 0) flushOut();
92
+ res(v);
93
+ };
94
+ const onClose = () => settle("/quit");
47
95
  rl.once("close", onClose);
48
96
  rl.question(q, (a) => {
49
97
  rl.off("close", onClose);
50
- res(a);
98
+ settle(a);
51
99
  });
52
100
  });
53
101
 
@@ -56,6 +104,10 @@ async function main() {
56
104
  let session: any = null;
57
105
  let relay: any = null;
58
106
  let turnActive = false;
107
+ // Pi-extension manager for the app's extensions screen (built after services below).
108
+ let extensions: ReturnType<typeof makeExtensionsControl> | null = null;
109
+ // Skills manager for the app's skills screen (built after services below).
110
+ let skills: ReturnType<typeof makeSkillsControl> | null = null;
59
111
 
60
112
  // The relay bridge: wires the app (when /remote-access is on) to the same gate +
61
113
  // turn loop. Its gate hooks (getRemote/remoteAsk) are handed to the gate below,
@@ -63,24 +115,139 @@ async function main() {
63
115
  const bridge = new RemoteBridge({
64
116
  onPrompt: (text) => void runTurn(text, true),
65
117
  onInterrupt: () => void session?.abort?.(),
66
- // On (re)attach, resync the transcript AND push live context (model +
67
- // version) so the app's session banner shows what this terminal is really
68
- // running. NON-PII: no cwd see RelayClient.sendContext.
118
+ // The account signed this terminal out from the app (session revoked). Drop the
119
+ // relay and wipe the local machine login so we don't keep reconnecting with a dead
120
+ // token; the user re-runs /login to sign back in.
121
+ onRevoked: () => {
122
+ try { relay?.stop(); } catch { /* already stopped */ }
123
+ relay = null;
124
+ priv.handleServerRevoke();
125
+ console.log(`\n${YELLOW}⟿ Signed out — this terminal's Privateer session was revoked from the app. Run /login to sign back in.${RESET}`);
126
+ },
127
+ // A slash command typed in the app composer (e.g. /model) — echo it (like the
128
+ // prompt echo), then run it through the same dispatcher the local REPL uses.
129
+ // Anything the dispatcher doesn't recognize falls through to the turn loop, so
130
+ // Pi/extension/skill commands (which session.prompt executes) work remotely too.
131
+ onCommand: (text) => {
132
+ console.log(`\n${DIM}⟿ [app] ${text}${RESET}`);
133
+ void (async () => { if (!(await runCommand(text, true))) await runTurn(text, true, false); })();
134
+ },
135
+ // On (re)attach, resync the transcript, push live context (model + version) so
136
+ // the app's banner shows what this terminal runs, AND advertise the available
137
+ // commands so the composer can autocomplete them (incl. extension commands). The
138
+ // model catalog isn't pushed — /model relays it on demand as a selection prompt.
139
+ // NON-PII: no cwd — see RelayClient.sendContext.
69
140
  onControllerAttached: () => {
70
141
  relay?.sendSnapshot([]);
71
- relay?.sendContext({ model: spec, version: agentVersion() });
142
+ relay?.sendContext({ model: currentSpec, version: agentVersion() });
143
+ relay?.sendCommands(availableCommands());
72
144
  },
73
145
  onStatus: (t) => console.log(`\n${DIM}⟿ ${t}${RESET}`),
146
+ // The app's extensions manager: list the user's installed Pi extensions, or
147
+ // add/remove one. add/remove persist immediately but only load on the next
148
+ // terminal launch — so the final frame flags needsRestart. See runExtMutation.
149
+ onExtensionsList: () => relay?.sendExtensions({ installed: extensions?.listInstalled() ?? [] }),
150
+ onExtensionsAdd: (source, sig, ts) => void runExtMutation("add", source, sig, ts),
151
+ onExtensionsRemove: (source, sig, ts) => void runExtMutation("remove", source, sig, ts),
152
+ // The app's skills manager: list the terminal's skills, or create/delete/toggle
153
+ // a user one. A create/delete/toggle only reaches the model's <available_skills>
154
+ // on the next launch (needsRestart); Run-now goes through the command frame as
155
+ // /skill:name, which Pi expands immediately. See runSkillMutation.
156
+ onSkillsList: () => relay?.sendSkills({ items: skills?.listSkills() ?? [] }),
157
+ onSkillCreate: (skill, sig, ts) =>
158
+ void runSkillMutation("skills_create", { name: skill.name, description: skill.description, instructions: skill.instructions }, () => skills!.createSkill(skill), "Saved", sig, ts),
159
+ onSkillDelete: (name, sig, ts) => void runSkillMutation("skills_delete", { name }, () => skills!.deleteSkill(name), "Deleted", sig, ts),
160
+ onSkillSetEnabled: (name, enabled, sig, ts) =>
161
+ void runSkillMutation("skills_set_enabled", { name, enabled }, () => skills!.setEnabled(name, enabled), enabled ? "Enabled" : "Disabled", sig, ts),
74
162
  });
75
163
 
164
+ // Watch the subagent approval channel: a subagent child's gated action (dangerous
165
+ // shell / out-of-scope / destructive — the ones decideAuto forces to "ask") forwards
166
+ // here and relays to the app over this session's bridge. The bridge fails closed while
167
+ // no controller is attached, so an undriven terminal denies rather than auto-approves.
168
+ startParentApprovalRelay(bridge, { onError: () => { /* best-effort; a poll error must not crash a turn */ } });
169
+
170
+ // Verify an account-signed mutating control frame (H2) for this interactive terminal
171
+ // before it acts — a forged extensions_add installs code, a forged skills_create
172
+ // injects an auto-invoked skill. Binds this terminal's own relay id. Fail-closed: a
173
+ // missing relay or an unsigned/forged/stale frame refuses the mutation.
174
+ function guardInteractive(
175
+ action: string,
176
+ args: Record<string, unknown>,
177
+ sig?: string,
178
+ ts?: number,
179
+ ): { ok: boolean; message?: string } {
180
+ const termId = relay?.id as string | undefined;
181
+ if (!termId) return { ok: false, message: "Remote access isn't active on this terminal." };
182
+ return authorizeControl(termId, action, args, sig, ts);
183
+ }
184
+
185
+ // Run a skills create/delete/toggle for the app and relay the fresh list + result.
186
+ // The final frame flags needsRestart on success so the app tells the user the
187
+ // change reaches the model on relaunch (Run-now works without a restart).
188
+ async function runSkillMutation(
189
+ action: string,
190
+ args: Record<string, unknown>,
191
+ op: () => Promise<{ ok: boolean; message?: string }>,
192
+ verb: string,
193
+ sig?: string,
194
+ ts?: number,
195
+ ): Promise<void> {
196
+ if (!skills) return;
197
+ const auth = guardInteractive(action, args, sig, ts);
198
+ if (!auth.ok) {
199
+ relay?.sendSkills({ items: skills.listSkills(), message: auth.message });
200
+ return;
201
+ }
202
+ const res = await op();
203
+ relay?.sendSkills({
204
+ items: skills.listSkills(),
205
+ message: res.ok ? `${verb} — restart the terminal to update the model's skill list.` : res.message,
206
+ needsRestart: res.ok,
207
+ });
208
+ }
209
+
210
+ // Run an extensions add/remove for the app and relay progress → result. Progress
211
+ // events (npm install / git clone steps) push busy frames; the final frame carries
212
+ // the fresh list plus needsRestart so the app tells the user to relaunch to activate.
213
+ async function runExtMutation(kind: "add" | "remove", source: string, sig?: string, ts?: number): Promise<void> {
214
+ if (!extensions) return;
215
+ const auth = guardInteractive(kind === "add" ? "extensions_add" : "extensions_remove", { source }, sig, ts);
216
+ if (!auth.ok) {
217
+ relay?.sendExtensions({ installed: extensions.listInstalled(), message: auth.message });
218
+ return;
219
+ }
220
+ extensions.setProgress((ev) =>
221
+ relay?.sendExtensions({
222
+ installed: extensions!.listInstalled(),
223
+ busy: ev.type !== "complete" && ev.type !== "error",
224
+ message: ev.message,
225
+ }),
226
+ );
227
+ try {
228
+ const res = kind === "add" ? await extensions.add(source) : await extensions.remove(source);
229
+ relay?.sendExtensions({
230
+ installed: extensions.listInstalled(),
231
+ message: res.ok
232
+ ? `${kind === "add" ? "Added" : "Removed"} ${source} — restart the terminal to activate.`
233
+ : res.message,
234
+ needsRestart: res.ok,
235
+ });
236
+ } finally {
237
+ extensions.setProgress(undefined);
238
+ }
239
+ }
240
+
76
241
  // Serialize turns so a remote prompt and a locally-typed one can't overlap.
77
- async function runTurn(text: string, remote: boolean): Promise<void> {
242
+ // `echo` prints the "⟿ [app] …" line; the caller suppresses it when it already
243
+ // echoed (a fall-through command from onCommand).
244
+ async function runTurn(text: string, remote: boolean, echo = true): Promise<void> {
78
245
  if (turnActive) {
79
246
  console.log(`\n${DIM}(busy — a turn is already running)${RESET}`);
80
247
  return;
81
248
  }
82
249
  turnActive = true;
83
- if (remote) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
250
+ if (remote && echo) console.log(`\n${DIM}⟿ [app] ${text.slice(0, 80)}${RESET}`);
84
251
  try {
85
252
  await session.prompt(text);
86
253
  } catch (e) {
@@ -106,6 +273,16 @@ async function main() {
106
273
  getRemote: bridge.getRemote,
107
274
  getNoQuarter: bridge.getNoQuarter,
108
275
  remoteAsk: bridge.remoteAsk,
276
+ // Subagents (and their child-only intercom tools) can't be driven from the app
277
+ // yet — pi-subagents runs each in a child session whose gate/UI bypass the relay,
278
+ // so its prompts surface on THIS terminal, invisible to the driver. Block them on
279
+ // a driven turn (fail-closed) and post a notice so the app shows why it stopped.
280
+ blockedWhenRemote: isRemoteUnsafeTool,
281
+ onRemoteBlocked: (toolName) => {
282
+ const msg = `${toolName} is disabled while driving remotely — its prompts can't reach the app.`;
283
+ console.log(`\n${DIM}⛔ ${msg}${RESET}`);
284
+ bridge.sendNotice(msg);
285
+ },
109
286
  };
110
287
 
111
288
  console.log(`${DIM}privateer-agent — lean REPL. Loading ${provider}/${modelId}…${RESET}`);
@@ -120,6 +297,15 @@ async function main() {
120
297
  });
121
298
  for (const d of services.diagnostics) if (d.type === "error") console.log(`${RED}! ${d.message}${RESET}`);
122
299
 
300
+ // Pi-extension management for the app's extensions screen. Reuse the session's own
301
+ // SettingsManager so the list reflects exactly what Pi loaded (and add/remove write
302
+ // the same settings.json). The Privateer moat is excluded — it's shim files, not
303
+ // configured "packages" (see extensionsControl).
304
+ extensions = makeExtensionsControl({ cwd, agentDir: agentDir(), settingsManager: services.settingsManager });
305
+ // Skills manager for the app's skills screen. Same SettingsManager so configured
306
+ // skill paths + the user's <agentDir>/skills are both discovered.
307
+ skills = makeSkillsControl({ cwd, agentDir: agentDir(), settingsManager: services.settingsManager });
308
+
123
309
  // Exit cleanup: revoke the server-side sessions THIS run created (the child API
124
310
  // session AND the account inference session) so the terminal drops off the app's
125
311
  // Linked Devices list the instant it closes — instead of lingering ~24h until its
@@ -164,19 +350,78 @@ async function main() {
164
350
  model,
165
351
  } as any));
166
352
 
353
+ // Dialog UI for extensions/skills that ask the user to CHOOSE mid-turn (Pi's
354
+ // ctx.ui.select/confirm/input). Without a bound uiContext Pi hands extensions a
355
+ // no-op UI, so those prompts silently resolve to "cancelled" — the agent could
356
+ // never ask a question. A remote-driven turn relays the choice to the app as the
357
+ // same `select_request` the /model picker uses (→ the app's SelectionSheet, then
358
+ // a `select_response` back); a local turn falls back to the terminal. This is a
359
+ // separate channel from the permission gate (which relays as `approval_request`
360
+ // → the app's global approval modal). Binding it also flips ctx.hasUI true, which
361
+ // is correct: this REPL is interactive. The abort signal Pi passes (to dismiss a
362
+ // dialog on interrupt) is threaded through so a cancelled turn doesn't wedge.
363
+ const driven = (): boolean => bridge.getRemote() && bridge.isConnected();
364
+ const uiContext = {
365
+ // Pick one of `options`. Returns the chosen string, or undefined if cancelled.
366
+ async select(title: string, options: string[], opts?: { signal?: AbortSignal }): Promise<string | undefined> {
367
+ if (!options.length) return undefined;
368
+ if (driven()) {
369
+ const choice = await bridge.selectRemote(
370
+ { title, options: options.map((o) => ({ value: o, label: o })) },
371
+ opts?.signal,
372
+ );
373
+ return choice ?? undefined;
374
+ }
375
+ flushOut(); // drain buffered stream output before the option list prints
376
+ console.log(`\n${YELLOW}${title}${RESET}`);
377
+ options.forEach((o, i) => console.log(` ${DIM}${i + 1}.${RESET} ${o}`));
378
+ const n = Number((await ask(`Choose [1-${options.length}]: `)).trim());
379
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? options[n - 1] : undefined;
380
+ },
381
+ // Yes/No. Remotely a two-option selection (the app has no dedicated confirm UI).
382
+ async confirm(title: string, message: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
383
+ if (driven()) {
384
+ const choice = await bridge.selectRemote(
385
+ { title: title || message, options: [{ value: "yes", label: "Yes" }, { value: "no", label: "No" }] },
386
+ opts?.signal,
387
+ );
388
+ return choice === "yes";
389
+ }
390
+ const a = (await ask(`\n${YELLOW}${title}${message ? ` — ${message}` : ""}${RESET} [y/N] `)).trim().toLowerCase();
391
+ return a === "y" || a === "yes";
392
+ },
393
+ // Free-form text. A remote turn relays a text-input prompt to the app (its
394
+ // own input sheet); a local turn reads the line over the terminal.
395
+ async input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined> {
396
+ if (driven()) {
397
+ const value = await bridge.inputRemote({ title, placeholder }, opts?.signal);
398
+ return value ?? undefined;
399
+ }
400
+ const a = await ask(`\n${YELLOW}${title}${placeholder ? ` (${placeholder})` : ""}: ${RESET}`);
401
+ return a === "/quit" ? undefined : a;
402
+ },
403
+ // A one-line status message: printed locally and surfaced in the app's feed.
404
+ notify(message: string, type?: "info" | "warning" | "error"): void {
405
+ const color = type === "error" ? RED : type === "warning" ? YELLOW : DIM;
406
+ console.log(`${color}${message}${RESET}`);
407
+ if (driven()) bridge.sendNotice(message);
408
+ },
409
+ };
410
+ await (session as any).bindExtensions({ uiContext });
411
+
167
412
  // Stream the turn as EngineEvents — printed locally AND forwarded to the app
168
413
  // (the relay only sends when a controller is attached, so this is safe always).
169
414
  const adapter = createEngineEventAdapter();
170
415
  session.subscribe((ev: any) => {
171
416
  for (const ee of adapter.toEngineEvents(ev)) {
172
417
  bridge.forwardEvent(ee);
173
- if (ee.type === "text") process.stdout.write(ee.text);
174
- else if (ee.type === "reasoning") process.stdout.write(`${DIM}${ee.text}${RESET}`);
175
- else if (ee.type === "tool-call") process.stdout.write(`\n${CYAN}⏺ ${ee.name}${RESET} ${DIM}${JSON.stringify(ee.input).slice(0, 120)}${RESET}\n`);
176
- else if (ee.type === "tool-result") process.stdout.write(`${DIM} ↳ ${String(ee.output).slice(0, 200)}${RESET}\n`);
177
- else if (ee.type === "tool-error") process.stdout.write(`\n${RED}✗ ${ee.name}: ${ee.error}${RESET}\n`);
178
- else if (ee.type === "error") process.stdout.write(`\n${RED}error: ${ee.error}${RESET}\n`);
179
- else if (ee.type === "finish") process.stdout.write("\n");
418
+ if (ee.type === "text") out(ee.text);
419
+ else if (ee.type === "reasoning") out(`${DIM}${ee.text}${RESET}`);
420
+ else if (ee.type === "tool-call") out(`\n${CYAN}⏺ ${ee.name}${RESET} ${DIM}${JSON.stringify(ee.input).slice(0, 120)}${RESET}\n`);
421
+ else if (ee.type === "tool-result") out(`${DIM} ↳ ${String(ee.output).slice(0, 200)}${RESET}\n`);
422
+ else if (ee.type === "tool-error") out(`\n${RED}✗ ${ee.name}: ${ee.error}${RESET}\n`);
423
+ else if (ee.type === "error") out(`\n${RED}error: ${ee.error}${RESET}\n`);
424
+ else if (ee.type === "finish") { out("\n"); flushOut(); }
180
425
  }
181
426
  });
182
427
 
@@ -235,27 +480,141 @@ async function main() {
235
480
  : `${DIM}Not signed in. /login to enable remote access & the account provider.${RESET}`,
236
481
  );
237
482
 
238
- const HELP = "Commands: /remote-access <on|off> /login /models [filter] /verify /mode <…> /quit";
483
+ const HELP = "Commands: /remote-access <on|off> /login /model <provider/id> /models [filter] /verify /mode <…> /quit";
239
484
  console.log(`${DIM}Ready. Type a prompt. ${HELP}${RESET}`);
240
485
  await showPosture();
241
486
 
487
+ // The available model catalog as sorted "provider/id" specs. Same source the
488
+ // /models list and the app's picker draw from.
489
+ async function availableModelSpecs(): Promise<string[]> {
490
+ const all: any[] = (services.modelRegistry as any).getAvailable ? await (services.modelRegistry as any).getAvailable() : [];
491
+ return all.map((m) => `${m.provider}/${m.id}`).sort();
492
+ }
493
+
494
+ // Switch the live session's model in place (history preserved — see
495
+ // AgentSession.setModel). Re-pushes context so the app's banner follows the
496
+ // change; feedback goes to the console AND (when driven) the app.
497
+ async function switchModel(specArg: string, remote: boolean): Promise<void> {
498
+ const sp = specArg.trim();
499
+ const at = sp.indexOf("/");
500
+ if (at < 0) { const m = "Usage: /model provider/id"; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
501
+ const p = sp.slice(0, at), id = sp.slice(at + 1);
502
+ const model = (session.modelRegistry as any).find?.(p, id) ?? (services.modelRegistry as any).find?.(p, id);
503
+ if (!model) { const m = `Model ${sp} not found — try /models.`; console.log(`${RED}${m}${RESET}`); if (remote) relay?.sendNotice(m); return; }
504
+ try {
505
+ await session.setModel(model);
506
+ currentSpec = sp;
507
+ const m = `model → ${sp}`;
508
+ console.log(`${DIM}${m}${RESET}`);
509
+ relay?.sendContext({ model: currentSpec, version: agentVersion() }); // banner follows the switch
510
+ if (remote) relay?.sendNotice(m);
511
+ } catch (e) {
512
+ const m = `Couldn't switch model: ${(e as Error).message}`;
513
+ console.log(`${RED}${m}${RESET}`);
514
+ if (remote) relay?.sendNotice(m);
515
+ }
516
+ }
517
+
518
+ // The terminal-driven model picker: relay THIS machine's real catalog to the app
519
+ // as a selection prompt and switch to whatever the driver picks. This is the
520
+ // remote /model flow — the terminal owns the options, the app just renders them.
521
+ async function pickModelRemote(filter: string): Promise<void> {
522
+ const specs = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
523
+ const choice = await bridge.selectRemote({
524
+ title: "Choose a model",
525
+ options: specs.map((sp) => ({ value: sp, label: sp })),
526
+ current: currentSpec,
527
+ });
528
+ if (choice) await switchModel(choice, true);
529
+ }
530
+
531
+ // Shared slash-command dispatcher for the local REPL and app-sent commands (the
532
+ // relay `command` frame). Returns true when `line` was a recognized command, so
533
+ // the REPL knows not to fall through and treat it as a prompt. `remote` commands
534
+ // are driven from the app: they never touch local stdin and mirror feedback back
535
+ // over the relay.
536
+ async function runCommand(line: string, remote: boolean): Promise<boolean> {
537
+ if (line === "/help" || line === "?") { console.log(`${DIM}${HELP}${RESET}`); if (remote) relay?.sendNotice(HELP); return true; }
538
+ if (line === "/verify") { await showPosture(); return true; }
539
+ // Enabling remote access is a physical-terminal action; ignore it if the app
540
+ // (already remote) asks. Disabling remotely is the /remote-access off path,
541
+ // which has its own terminate frame, so we don't handle it here for remote.
542
+ if (line === "/remote-access" || line === "/remote-access on" || line === "/remote") { if (!remote) await remoteAccess(true); return true; }
543
+ if (line === "/remote-access off") { if (!remote) await remoteAccess(false); return true; }
544
+ if (line === "/login") { if (!remote) await login(); return true; }
545
+ if (line.startsWith("/model ")) { await switchModel(line.slice(7), remote); return true; }
546
+ // Bare /model (or /models [filter]) → the picker. Remote: relay the catalog as
547
+ // a selection prompt the app renders; local: just print the list.
548
+ if (line === "/model" || line === "/models" || line.startsWith("/models ")) {
549
+ const filter = line.startsWith("/models ") ? line.slice(8).trim().toLowerCase() : "";
550
+ if (remote) { await pickModelRemote(filter); return true; }
551
+ const rows = (await availableModelSpecs()).filter((sp) => !filter || sp.toLowerCase().includes(filter));
552
+ console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
553
+ return true;
554
+ }
555
+ // Extensions: remote drives the app's manager (list frame); local prints the list.
556
+ if (line === "/extensions" || line === "/ext") {
557
+ const installed = extensions?.listInstalled() ?? [];
558
+ if (remote) { relay?.sendExtensions({ installed }); return true; }
559
+ console.log(installed.length ? installed.map((e) => ` ${e.source}${e.installed ? "" : ` ${DIM}(not installed)${RESET}`}`).join("\n") : `${DIM}No extensions installed. Add them from the Privateer app.${RESET}`);
560
+ return true;
561
+ }
562
+ // Skills: remote drives the app's manager (list frame); local prints the list.
563
+ // Note `/skill:name` (invoke) is NOT handled here — it falls through to Pi.
564
+ if (line === "/skills") {
565
+ const items = skills?.listSkills() ?? [];
566
+ if (remote) { relay?.sendSkills({ items }); return true; }
567
+ console.log(items.length ? items.map((s) => ` ${s.name}${s.disabled ? ` ${DIM}(disabled)${RESET}` : ""}${s.editable ? "" : ` ${DIM}(read-only)${RESET}`} — ${s.description}`).join("\n") : `${DIM}No skills yet. Create them from the Privateer app.${RESET}`);
568
+ return true;
569
+ }
570
+ if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; const m = `mode → ${mode}`; console.log(`${DIM}${m}${RESET}`); if (remote) relay?.sendNotice(m); return true; }
571
+ // Bare /mode → the picker (remote) or a hint (local).
572
+ if (line === "/mode") {
573
+ if (remote) {
574
+ const choice = await bridge.selectRemote({
575
+ title: "Permission mode",
576
+ options: ["default", "acceptEdits", "plan", "bypass"].map((v) => ({ value: v, label: v })),
577
+ current: mode,
578
+ });
579
+ if (choice) { mode = choice as typeof mode; relay?.sendNotice(`mode → ${mode}`); }
580
+ } else {
581
+ console.log(`${DIM}modes: default · acceptEdits · plan · bypass (current: ${mode})${RESET}`);
582
+ }
583
+ return true;
584
+ }
585
+ // Not one of THIS CLI's built-ins → not handled here. Both the local REPL and
586
+ // the remote onCommand fall through to the turn loop, where Pi executes any
587
+ // extension/skill command (or treats it as a prompt).
588
+ return false;
589
+ }
590
+
591
+ // The commands the app should offer in its composer: this CLI's built-ins plus
592
+ // whatever Pi extensions have registered (so the palette reflects the real,
593
+ // extension-dependent command set — not a hardcoded list). Pushed on attach.
594
+ function availableCommands(): { name: string; description?: string }[] {
595
+ const builtins = [
596
+ { name: "/model", description: "Switch the model" },
597
+ { name: "/mode", description: "Change the approval mode (default/acceptEdits/plan/bypass)" },
598
+ { name: "/models", description: "List available models" },
599
+ { name: "/extensions", description: "Manage installed Pi extensions" },
600
+ { name: "/skills", description: "Manage the terminal's skills" },
601
+ { name: "/verify", description: "Re-check the model's privacy posture" },
602
+ { name: "/help", description: "Show available commands" },
603
+ ];
604
+ let ext: { name: string; description?: string }[] = [];
605
+ try {
606
+ const reg = (session?.extensionRunner as any)?.getRegisteredCommands?.() ?? [];
607
+ ext = reg.map((c: any) => ({ name: `/${c.invocationName ?? c.name}`, description: c.description }));
608
+ } catch { /* no session/extensions yet */ }
609
+ const seen = new Set(builtins.map((c) => c.name));
610
+ return [...builtins, ...ext.filter((c) => !seen.has(c.name))];
611
+ }
612
+
242
613
  for (;;) {
243
614
  const line = (await ask(`\n${CYAN}›${RESET} `)).trim();
244
615
  if (line === "/quit" || line === "/exit") break;
245
- if (line === "/verify") { await showPosture(); continue; }
246
- if (line === "/remote-access" || line === "/remote-access on" || line === "/remote") { await remoteAccess(true); continue; }
247
- if (line === "/remote-access off") { await remoteAccess(false); continue; }
248
- if (line === "/login") { await login(); continue; }
249
- if (line === "/help" || line === "?") { console.log(`${DIM}${HELP}${RESET}`); continue; }
250
- if (line.startsWith("/models")) {
251
- const filter = line.slice(7).trim().toLowerCase();
252
- const all: any[] = (services.modelRegistry as any).getAvailable ? await (services.modelRegistry as any).getAvailable() : [];
253
- const rows = all.map((m) => `${m.provider}/${m.id}`).filter((s) => !filter || s.toLowerCase().includes(filter)).sort();
254
- console.log(rows.slice(0, 40).join("\n") + (rows.length > 40 ? `\n${DIM}… ${rows.length - 40} more (try /models <filter>)${RESET}` : ""));
255
- continue;
256
- }
257
- if (line.startsWith("/mode ")) { mode = line.slice(6).trim() as typeof mode; console.log(`${DIM}mode → ${mode}${RESET}`); continue; }
258
616
  if (!line) continue;
617
+ if ((line.startsWith("/") || line === "?") && (await runCommand(line, false))) continue;
259
618
  await runTurn(line, false);
260
619
  }
261
620
  await cleanup();
@@ -0,0 +1,67 @@
1
+ // `privateer daemon [run|install|uninstall|status]` dispatcher.
2
+ //
3
+ // ORDERING CONTRACT: import ./boot.ts FIRST (env pin + attestation dispatcher), then
4
+ // DYNAMICALLY import the daemon (which pulls the Pi session stack) only when we
5
+ // actually run it — so boot's side effects are guaranteed to precede any Pi import.
6
+ // The service/status subcommands touch no Pi code, so they can run without paying the
7
+ // session-stack import cost.
8
+ import "../boot.ts";
9
+
10
+ function usage(): string {
11
+ return [
12
+ "Usage: privateer daemon [command]",
13
+ "",
14
+ " run Run the daemon in the foreground (default).",
15
+ " install Install it as a login service so it auto-starts and stays",
16
+ " reachable from the app even with no terminal open.",
17
+ " uninstall Remove the login service.",
18
+ " status Show whether the service is installed and the daemon is live.",
19
+ ].join("\n");
20
+ }
21
+
22
+ export async function runDaemonCli(argv: string[]): Promise<void> {
23
+ const sub = argv[0] ?? "run";
24
+ switch (sub) {
25
+ case "run": {
26
+ // Pi-touching — dynamic import AFTER boot.
27
+ const { runDaemon } = await import("../daemon/index.ts");
28
+ runDaemon(); // installs its own SIGINT/SIGTERM handlers and blocks
29
+ return;
30
+ }
31
+ case "install": {
32
+ const { installService } = await import("../daemon/service.ts");
33
+ try {
34
+ const info = installService();
35
+ process.stdout.write(`Privateer daemon installed as a login service.\n unit: ${info.unitPath}\n logs: ${info.logPath}\nIt will start now and on every login. Manage with \`privateer daemon status|uninstall\`.\n`);
36
+ } catch (err) {
37
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
38
+ process.exit(1);
39
+ }
40
+ return;
41
+ }
42
+ case "uninstall": {
43
+ const { uninstallService } = await import("../daemon/service.ts");
44
+ try {
45
+ uninstallService();
46
+ process.stdout.write("Privateer daemon login service removed.\n");
47
+ } catch (err) {
48
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
49
+ process.exit(1);
50
+ }
51
+ return;
52
+ }
53
+ case "status": {
54
+ const { statusReport } = await import("../daemon/service.ts");
55
+ process.stdout.write((await statusReport()) + "\n");
56
+ return;
57
+ }
58
+ case "-h":
59
+ case "--help":
60
+ case "help":
61
+ process.stdout.write(usage() + "\n");
62
+ return;
63
+ default:
64
+ process.stderr.write(`Unknown daemon command: ${sub}\n\n${usage()}\n`);
65
+ process.exit(1);
66
+ }
67
+ }