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