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.
- package/README.md +19 -0
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +35 -5
- package/extensions/privateer-brand.ts +213 -34
- package/extensions/privateer-context.ts +59 -0
- package/extensions/privateer-gate.ts +351 -6
- package/extensions/privateer-posture.ts +18 -2
- package/extensions/privateer-privacy.ts +50 -1
- package/package.json +4 -1
- package/src/auth/privateer.ts +151 -19
- package/src/channels/bridge.ts +293 -0
- package/src/channels/discord.ts +210 -0
- package/src/channels/run.ts +383 -0
- package/src/channels/slack.ts +176 -0
- package/src/channels/status.ts +54 -0
- package/src/channels/telegram.ts +139 -0
- package/src/channels/types.ts +36 -0
- package/src/channels/whatsapp.ts +178 -0
- package/src/cli/chat.ts +414 -28
- package/src/cli/daemonCli.ts +67 -0
- package/src/config/version.ts +16 -0
- package/src/context.ts +171 -0
- package/src/crypto/accountTrust.ts +113 -0
- package/src/crypto/accountVerify.ts +138 -0
- package/src/crypto/terminalKey.ts +95 -0
- package/src/crypto/terminalUnseal.ts +62 -0
- package/src/daemon/index.ts +522 -34
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- package/src/providers/account.ts +20 -12
- package/src/remote/channelsControl.ts +192 -0
- package/src/remote/controlAuth.ts +67 -0
- package/src/remote/extensionsControl.ts +140 -0
- package/src/remote/liveTaskSession.ts +218 -0
- package/src/remote/relayClient.ts +524 -0
- package/src/remote/remoteBridge.ts +172 -0
- package/src/remote/routinesControl.ts +216 -0
- package/src/remote/skillsControl.ts +205 -0
- package/src/remote/subagentChannel.ts +261 -0
- package/src/remote/subagentRelay.ts +126 -0
- package/src/remote/workflowsControl.ts +132 -0
- package/src/routines/store.ts +5 -1
- package/src/workflows/expr.ts +4 -0
- package/src/workflows/runner.ts +8 -0
- package/src/workflows/schema.ts +5 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
"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": {
|