privateer-agent 0.3.5 → 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 (48) hide show
  1. package/README.md +19 -0
  2. package/bin/privateer-daemon.mjs +30 -0
  3. package/bin/privateer-subagent.mjs +68 -0
  4. package/bin/privateer-tui +35 -5
  5. package/extensions/privateer-brand.ts +213 -34
  6. package/extensions/privateer-context.ts +59 -0
  7. package/extensions/privateer-gate.ts +351 -6
  8. package/extensions/privateer-posture.ts +18 -2
  9. package/extensions/privateer-privacy.ts +50 -1
  10. package/package.json +4 -1
  11. package/src/auth/privateer.ts +151 -19
  12. package/src/channels/bridge.ts +293 -0
  13. package/src/channels/discord.ts +210 -0
  14. package/src/channels/run.ts +383 -0
  15. package/src/channels/slack.ts +176 -0
  16. package/src/channels/status.ts +54 -0
  17. package/src/channels/telegram.ts +139 -0
  18. package/src/channels/types.ts +36 -0
  19. package/src/channels/whatsapp.ts +178 -0
  20. package/src/cli/chat.ts +414 -28
  21. package/src/cli/daemonCli.ts +67 -0
  22. package/src/config/version.ts +16 -0
  23. package/src/context.ts +171 -0
  24. package/src/crypto/accountTrust.ts +113 -0
  25. package/src/crypto/accountVerify.ts +138 -0
  26. package/src/crypto/terminalKey.ts +95 -0
  27. package/src/crypto/terminalUnseal.ts +62 -0
  28. package/src/daemon/index.ts +522 -34
  29. package/src/daemon/service.ts +232 -0
  30. package/src/ext/permissionGate.ts +38 -0
  31. package/src/permissions/classify.ts +49 -5
  32. package/src/providers/account.ts +20 -12
  33. package/src/remote/channelsControl.ts +192 -0
  34. package/src/remote/controlAuth.ts +67 -0
  35. package/src/remote/extensionsControl.ts +140 -0
  36. package/src/remote/liveTaskSession.ts +218 -0
  37. package/src/remote/relayClient.ts +524 -0
  38. package/src/remote/remoteBridge.ts +172 -0
  39. package/src/remote/routinesControl.ts +216 -0
  40. package/src/remote/skillsControl.ts +205 -0
  41. package/src/remote/subagentChannel.ts +261 -0
  42. package/src/remote/subagentRelay.ts +126 -0
  43. package/src/remote/workflowsControl.ts +132 -0
  44. package/src/routines/store.ts +5 -1
  45. package/src/workflows/expr.ts +4 -0
  46. package/src/workflows/runner.ts +8 -0
  47. package/src/workflows/schema.ts +5 -0
  48. package/src/workflows/store.ts +108 -0
@@ -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,222 @@ 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
+ }
218
+
219
+ // Persistent footer indicator for remote access. When the relay is up, the footer
220
+ // shows a GREEN "⟿ remote access" line so it's always obvious this terminal can be
221
+ // driven from the phone — with a reminder that `/remote-access off` stops it. We
222
+ // keep a UI handle (captured from session_start / the command ctx) so the relay's
223
+ // own connect/disconnect callbacks can refresh the indicator, not just the command.
224
+ const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", DIM = "\x1b[2m", RESET = "\x1b[0m";
225
+ const REMOTE_STATUS_KEY = "privateer:remote-access";
226
+ let uiRef: any = null;
227
+ // "off" → no indicator; "connecting" → relay starting or reconnecting (yellow);
228
+ // "connected" → socket open, controller reachable (green).
229
+ let remoteState: "off" | "connecting" | "connected" = "off";
230
+
231
+ function refreshRemoteStatus(): void {
232
+ const ui = uiRef;
233
+ if (!ui?.setStatus) return;
234
+ if (remoteState === "off") {
235
+ ui.setStatus(REMOTE_STATUS_KEY, undefined);
236
+ return;
237
+ }
238
+ const text =
239
+ remoteState === "connected"
240
+ ? `${GREEN}⟿ remote access${RESET} ${DIM}· /remote-access off to stop${RESET}`
241
+ : `${YELLOW}⟿ remote access · connecting…${RESET} ${DIM}· /remote-access off to stop${RESET}`;
242
+ ui.setStatus(REMOTE_STATUS_KEY, text);
243
+ }
244
+
245
+ function setRemoteState(s: typeof remoteState): void {
246
+ remoteState = s;
247
+ refreshRemoteStatus();
248
+ }
249
+
250
+ // Tear down the relay and clear the indicator. Used by `/remote-access off` AND by
251
+ // the app's own "End remote access" action (onTerminate), so both paths converge.
252
+ function disableRemote(): void {
253
+ relay?.stop();
254
+ relay = null;
255
+ setRemoteState("off");
256
+ }
32
257
 
33
258
  // Inbound app→CLI files land here (keyed by "#n"); save_attachment persists them.
34
259
  const attachments = new AttachmentStore();
@@ -36,6 +261,13 @@ let sinceLastPrompt: StoredAttachment[] = [];
36
261
 
37
262
  const bridge = new RemoteBridge({
38
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
+ }
39
271
  // Fold any files the app sent since the last prompt into a reference note so the
40
272
  // model knows they exist and can save_attachment them.
41
273
  const atts = sinceLastPrompt;
@@ -44,21 +276,78 @@ const bridge = new RemoteBridge({
44
276
  ? `\n\n[Files attached from the app: ${atts.map((a) => `#${a.n} ${a.name} (${a.mediaType})`).join(", ")}. ` +
45
277
  `Use the save_attachment tool with the ref number to write one to disk.]`
46
278
  : "";
47
- 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
+ }
48
287
  },
49
288
  onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
50
- onControllerAttached: () => relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]),
289
+ // The app asked to end remote access from its side — stop the relay locally too so
290
+ // the terminal doesn't keep reconnecting, and clear the green indicator.
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),
304
+ onControllerAttached: () => {
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.
308
+ setRemoteState("connected");
309
+ relay?.sendSnapshot([{ kind: "notice", text: "Privateer terminal connected." }]);
310
+ relay?.sendContext({ model: currentSpec, version: agentVersion() });
311
+ relay?.sendCommands(advertiseCommands());
312
+ },
51
313
  onAttachment: (file) => sinceLastPrompt.push(attachments.register(file)),
52
- onStatus: () => {},
314
+ // Drive the indicator from the relay's own status stream: "connected" green;
315
+ // its reconnect/retry notices → yellow "connecting…". Ignored once we're off.
316
+ onStatus: (text) => {
317
+ if (!relay) return;
318
+ if (/disconnect|reconnect|retry|couldn't|could not/i.test(text)) setRemoteState("connecting");
319
+ else if (/connected/i.test(text)) setRemoteState("connected");
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"),
53
333
  });
54
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
+
55
344
  const gate = makePermissionGate({
56
345
  getMode: () => mode,
57
346
  setMode: (m) => (mode = m),
58
347
  allowlist,
59
348
  allowedOutsideRoots,
60
349
  cwd: process.cwd(),
61
- localAsk: defaultLocalAsk,
350
+ localAsk,
62
351
  getRemote: bridge.getRemote,
63
352
  getNoQuarter: bridge.getNoQuarter,
64
353
  remoteAsk: bridge.remoteAsk,
@@ -68,6 +357,14 @@ export default function privateerControl(pi: any): void {
68
357
  piRef = pi;
69
358
  gate(pi); // tool_call (block/allow) + tool_result (redact)
70
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
+
71
368
  // File transfer both ways: send_file_to_client (CLI→app, via the bridge's relay) and
72
369
  // save_attachment (app→CLI, from the AttachmentStore inbound files land in). Both
73
370
  // live here because they share the RemoteBridge / its attachment stream.
@@ -88,11 +385,29 @@ export default function privateerControl(pi: any): void {
88
385
  // and the user hasn't pinned a mode via PRIVATEER_MODE.
89
386
  const HEADLESS = new Set(["json", "print", "rpc"]);
90
387
  pi.on("session_start", (_e: any, ctx: any) => {
388
+ // Capture the UI handle so the relay's connect/disconnect callbacks can refresh
389
+ // the footer indicator (they fire outside any command's ctx). Re-render in case
390
+ // remote access was already on when the session (re)started.
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);
396
+ refreshRemoteStatus();
91
397
  if (ctx?.mode && HEADLESS.has(ctx.mode) && (process.env.PRIVATEER_MODE ?? "") === "") {
92
398
  mode = "bypass";
93
399
  }
94
400
  });
95
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
+
96
411
  // Forward turn events to the app. The relay only sends when a controller is
97
412
  // attached, so this is safe on every turn (local or remote).
98
413
  const adapter = createEngineEventAdapter();
@@ -108,6 +423,7 @@ export default function privateerControl(pi: any): void {
108
423
  pi.on("agent_end", (ev: any) => {
109
424
  fwd(ev);
110
425
  bridge.settleTurn();
426
+ remoteTurnActive = false; // turn finished → the next app prompt may start one
111
427
  });
112
428
 
113
429
  pi.registerCommand?.("mode", {
@@ -120,19 +436,48 @@ export default function privateerControl(pi: any): void {
120
436
  },
121
437
  });
122
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
+
123
467
  pi.registerCommand?.("remote-access", {
124
468
  description: "Drive this terminal from the Privateer app: /remote-access on | off",
125
469
  handler: async (args: string, ctx: any) => {
470
+ if (ctx?.ui) uiRef = ctx.ui; // keep the handle fresh for relay-driven refreshes
126
471
  const off = String(args ?? "").trim().toLowerCase() === "off";
127
472
  if (off) {
128
- relay?.stop();
129
- relay = null;
473
+ disableRemote();
130
474
  return ctx.ui?.notify?.("remote access off", "info");
131
475
  }
132
476
  if (relay) return ctx.ui?.notify?.("remote access already on", "info");
133
477
  if (!priv.hasCredentials()) return ctx.ui?.notify?.("Not signed in to Privateer.", "warning");
134
478
  relay = new RelayClient(bridge.callbacks, { label: "privateer-cli" });
135
479
  bridge.attachRelay(relay);
480
+ setRemoteState("connecting"); // yellow until the relay reports connected
136
481
  await relay.start();
137
482
  ctx.ui?.notify?.("Remote access on — approve this terminal in the Privateer app, then drive it from there.", "info");
138
483
  },
@@ -1,7 +1,7 @@
1
1
  // The privacy-posture badge in Pi's status bar (Phase 6 polish). On model select
2
2
  // (and at session start) it computes the current model's posture and pins it to the
3
- // footer via ctx.ui.setStatus — so the moat is *visible*: a green "Verified TEE"
4
- // for an attested enclave, a distinct label for a mere ZDR claim.
3
+ // footer via ctx.ui.setStatus — so the moat is *visible*: a green shield "Trusted
4
+ // Execution" for an attested enclave, a distinct label for a mere ZDR claim.
5
5
  //
6
6
  // Handles both surfaces: the account channel (privateer/*, via server-proxy
7
7
  // attestation) which pi-privacy doesn't know, and everything else via pi-privacy.
@@ -11,6 +11,20 @@ import { accountPosture } from "../src/providers/account.ts";
11
11
 
12
12
  const DOT: Record<string, string> = { green: "🟢", yellow: "🟡", red: "🔴", neutral: "⚪" };
13
13
 
14
+ // ANSI so the shield "references the previous color": the TEE tiers used to show a
15
+ // green/yellow traffic-light dot — now they show a shield tinted the same color
16
+ // (green = verified, yellow = unconfirmed). The status bar renders these escapes.
17
+ const GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RESET = "\x1b[0m";
18
+
19
+ // The TEE tiers render as a colored shield + "Trusted Execution" (pi-privacy labels
20
+ // these "Verified TEE" / "TEE (unconfirmed)"; we rename to Trusted Execution for the
21
+ // privateer badge and swap the dot for a shield). Everything else keeps the dot.
22
+ function badgeLabel(tier: PrivacyTier): string | null {
23
+ if (tier === "tee-verified") return `${GREEN}⛉ Trusted Execution${RESET}`;
24
+ if (tier === "tee-unverified") return `${YELLOW}⛉ Trusted Execution (unconfirmed)${RESET}`;
25
+ return null;
26
+ }
27
+
14
28
  async function badgeFor(provider: string, modelId: string): Promise<string> {
15
29
  const res =
16
30
  provider === "privateer"
@@ -18,6 +32,8 @@ async function badgeFor(provider: string, modelId: string): Promise<string> {
18
32
  : await verifyModelPosture(provider, modelId, {
19
33
  apiKey: provider === "nearai" ? process.env.NEARAI_API_KEY ?? process.env.NEAR_AI_API_KEY : undefined,
20
34
  });
35
+ const shield = badgeLabel(res.tier as PrivacyTier);
36
+ if (shield) return shield;
21
37
  const info = TIERS[res.tier as PrivacyTier];
22
38
  return `${DOT[info.posture] ?? "⚪"} ${info.label}`;
23
39
  }
@@ -4,12 +4,61 @@
4
4
  // (actually confidential-compute TEE) is treated as verified-private (no PII
5
5
  // over-warning), and a zdr account model as zdr-policy. Replaces loading pi-privacy's
6
6
  // default entry directly.
7
+ //
8
+ // It also WIDENS the tinfoil provider's model list. pi-privacy registers `tinfoil` with
9
+ // a single seed model, so any other Tinfoil model — notably our default `tinfoil/glm-5-2`
10
+ // — resolves as a "custom model id" with a startup warning and never shows in the picker.
11
+ // We re-register tinfoil with its current chat catalog AFTER pi-privacy runs (a second
12
+ // registerProvider call replaces the provider's model list; pi-privacy registers
13
+ // synchronously, so ours lands second and wins). This is purely a display/resolution
14
+ // list — posture and attestation are dispatcher-bound and unaffected by the model set.
7
15
  import { makePiPrivacyExtension } from "pi-privacy";
8
16
  import { accountPosture } from "../src/providers/account.ts";
9
17
 
10
- export default makePiPrivacyExtension({
18
+ // Tinfoil's live chat models (inference.tinfoil.sh/v1/models), glm-5-2 first — the
19
+ // launcher's default. Non-chat endpoints (embeddings, tts, whisper, websearch,
20
+ // doc-upload) are intentionally omitted. Refresh from the live catalog if Tinfoil adds
21
+ // models; this static list just needs to cover what we default to and commonly pick.
22
+ const TINFOIL_MODELS = [
23
+ "glm-5-2",
24
+ "kimi-k2-6",
25
+ "deepseek-v4-pro",
26
+ "gpt-oss-120b",
27
+ "gpt-oss-safeguard-120b",
28
+ "gemma4-31b",
29
+ "llama3-3-70b",
30
+ ];
31
+
32
+ function tinfoilModel(id: string) {
33
+ return {
34
+ id,
35
+ name: id,
36
+ reasoning: false,
37
+ input: ["text"] as ("text" | "image")[],
38
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
39
+ contextWindow: 128000,
40
+ maxTokens: 4096,
41
+ };
42
+ }
43
+
44
+ const privacy = makePiPrivacyExtension({
11
45
  resolveTier: async (provider, modelId) => {
12
46
  if (provider !== "privateer") return undefined; // pi-privacy handles its own providers
13
47
  return (await accountPosture(modelId)).tier;
14
48
  },
15
49
  });
50
+
51
+ export default function privateerPrivacy(pi: any): void {
52
+ privacy(pi);
53
+ // Re-register tinfoil with the fuller catalog. Mirrors pi-privacy's provider config
54
+ // (baseUrl/api + ${TINFOIL_API_KEY} template with authHeader); only the model list is
55
+ // widened so `tinfoil/glm-5-2` and friends resolve without the "custom model id" warning.
56
+ pi.registerProvider?.("tinfoil", {
57
+ name: "Tinfoil (private TEE inference)",
58
+ baseUrl: "https://inference.tinfoil.sh/v1",
59
+ api: "openai-completions",
60
+ apiKey: "${TINFOIL_API_KEY}",
61
+ authHeader: true,
62
+ models: TINFOIL_MODELS.map(tinfoilModel),
63
+ });
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Privateer — a provider-agnostic, safe-by-default terminal coding agent with TEE/Tinfoil attestation, rebuilt on the Pi toolkit. Bring your own model across 20 providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,6 +43,7 @@
43
43
  "scripts": {
44
44
  "start": "tsx src/main.ts",
45
45
  "chat": "node --env-file=.env --import tsx src/cli/chat.ts",
46
+ "channels": "node --env-file=.env --import tsx src/channels/run.ts",
46
47
  "dev": "tsx watch src/main.ts",
47
48
  "typecheck": "tsc --noEmit",
48
49
  "test": "for f in tests/*.test.ts; do node --import tsx --test \"$f\" || exit 1; done"
@@ -62,11 +63,13 @@
62
63
  "pi-mcp-adapter": "^2.11.0",
63
64
  "pi-privacy": "^0.3.0",
64
65
  "pi-subagents": "^0.34.0",
66
+ "privateer-workflow": "^0.1.0",
65
67
  "picomatch": "^4.0.4",
66
68
  "tsx": "^4.16.0",
67
69
  "typebox": "^1.3.4",
68
70
  "undici": "^7.28.0",
69
71
  "ws": "^8.21.0",
72
+ "yaml": "^2.5.0",
70
73
  "zod": "^4.0.16"
71
74
  },
72
75
  "devDependencies": {