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.
- package/bin/privateer-daemon.mjs +30 -0
- package/bin/privateer-subagent.mjs +68 -0
- package/bin/privateer-tui +19 -0
- package/extensions/privateer-brand.ts +61 -20
- package/extensions/privateer-gate.ts +290 -3
- package/package.json +4 -1
- package/src/auth/privateer.ts +45 -6
- 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 +389 -30
- package/src/cli/daemonCli.ts +67 -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 +511 -46
- package/src/daemon/service.ts +232 -0
- package/src/ext/permissionGate.ts +38 -0
- package/src/permissions/classify.ts +49 -5
- 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 +512 -1
- 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
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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:
|
|
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
|
-
|
|
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")
|
|
174
|
-
else if (ee.type === "reasoning")
|
|
175
|
-
else if (ee.type === "tool-call")
|
|
176
|
-
else if (ee.type === "tool-result")
|
|
177
|
-
else if (ee.type === "tool-error")
|
|
178
|
-
else if (ee.type === "error")
|
|
179
|
-
else if (ee.type === "finish")
|
|
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
|
+
}
|