privateer-agent 0.2.1 → 0.3.1
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 +167 -442
- package/bin/privateer-tui +86 -0
- package/bin/privateer.mjs +17 -6
- package/bin/pv +28 -0
- package/package.json +26 -23
- package/src/auth/privateer.ts +57 -0
- package/src/boot.ts +43 -0
- package/src/bridge/engineAdapter.ts +182 -0
- package/src/cli/chat.ts +243 -0
- package/src/config/paths.ts +24 -46
- package/src/config/permissionMode.ts +5 -0
- package/src/crypto/outboxSeal.ts +61 -0
- package/src/daemon/index.ts +204 -121
- package/src/daemon/ipc.ts +1 -1
- package/src/engine/errors.ts +85 -45
- package/src/engine/router.ts +11 -165
- package/src/ext/permissionGate.ts +216 -0
- package/src/main.ts +32 -0
- package/src/permissions/classify.ts +172 -0
- package/src/permissions/gate.ts +11 -14
- package/src/permissions/mode.ts +6 -2
- package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
- package/src/providers/account.ts +170 -0
- package/src/providers/catalog.ts +77 -61
- package/src/providers/genModelsJson.ts +97 -0
- package/src/remote/relayClient.ts +15 -0
- package/src/remote/remoteBridge.ts +152 -0
- package/src/routines/delivery.ts +118 -9
- package/src/routines/schema.ts +24 -5
- package/src/routines/store.ts +44 -1
- package/src/routines/toolSelect.ts +3 -1
- package/src/session.ts +81 -273
- package/src/tools/routine.ts +120 -101
- package/src/tools/saveAttachment.ts +39 -42
- package/src/tools/sendFile.ts +75 -0
- package/src/util/attachmentStore.ts +18 -35
- package/src/util/redact.ts +33 -3
- package/LICENSE +0 -21
- package/src/agents/loader.ts +0 -49
- package/src/commands/custom.ts +0 -123
- package/src/commands/registry.ts +0 -618
- package/src/components/AgentGroupView.tsx +0 -104
- package/src/components/App.tsx +0 -1626
- package/src/components/ApprovalPrompt.tsx +0 -49
- package/src/components/Banner.tsx +0 -78
- package/src/components/Markdown.tsx +0 -183
- package/src/components/ModeHint.tsx +0 -40
- package/src/components/ModelPicker.tsx +0 -302
- package/src/components/Onboarding.tsx +0 -203
- package/src/components/OptionPicker.tsx +0 -134
- package/src/components/PlanConfirm.tsx +0 -37
- package/src/components/PrivateerLogin.tsx +0 -109
- package/src/components/PromptInput.tsx +0 -602
- package/src/components/RewindPicker.tsx +0 -69
- package/src/components/Root.tsx +0 -116
- package/src/components/SessionPicker.tsx +0 -64
- package/src/components/StatusBar.tsx +0 -131
- package/src/components/TodoPanel.tsx +0 -36
- package/src/components/ToolCallView.tsx +0 -113
- package/src/components/Transcript.tsx +0 -210
- package/src/components/figures.ts +0 -14
- package/src/components/promptModel.ts +0 -73
- package/src/components/spinnerVerbs.ts +0 -46
- package/src/components/theme.ts +0 -57
- package/src/components/types.ts +0 -34
- package/src/components/useTeeShield.ts +0 -104
- package/src/components/useTerminalWidth.ts +0 -24
- package/src/components/useZdrShield.ts +0 -126
- package/src/config/load.ts +0 -115
- package/src/config/schema.ts +0 -94
- package/src/context/outputStyles.ts +0 -42
- package/src/context/projectInfo.ts +0 -59
- package/src/context/systemPrompt.ts +0 -176
- package/src/engine/QueryEngine.ts +0 -399
- package/src/hooks/engine.ts +0 -155
- package/src/main.tsx +0 -209
- package/src/mcp/client.ts +0 -251
- package/src/mcp/oauth.ts +0 -245
- package/src/memory/auto.ts +0 -146
- package/src/memory/checkpoints.ts +0 -227
- package/src/memory/store.ts +0 -127
- package/src/providers/attestation.ts +0 -149
- package/src/providers/capabilities.ts +0 -104
- package/src/providers/models.ts +0 -183
- package/src/providers/registry.ts +0 -71
- package/src/providers/resolve.ts +0 -78
- package/src/skills/installer.ts +0 -222
- package/src/skills/loader.ts +0 -88
- package/src/tools/askUser.ts +0 -92
- package/src/tools/bash.ts +0 -98
- package/src/tools/context.ts +0 -128
- package/src/tools/edit.ts +0 -67
- package/src/tools/exec.ts +0 -60
- package/src/tools/glob.ts +0 -39
- package/src/tools/grep.ts +0 -86
- package/src/tools/index.ts +0 -83
- package/src/tools/memory.ts +0 -53
- package/src/tools/processRegistry.ts +0 -77
- package/src/tools/read.ts +0 -42
- package/src/tools/sendFileToClient.ts +0 -55
- package/src/tools/skill.ts +0 -44
- package/src/tools/task.ts +0 -52
- package/src/tools/todo.ts +0 -36
- package/src/tools/todoStore.ts +0 -31
- package/src/tools/walk.ts +0 -44
- package/src/tools/web.ts +0 -145
- package/src/tools/worktree.ts +0 -145
- package/src/tools/write.ts +0 -40
- package/src/util/images.ts +0 -378
- package/src/util/limit.ts +0 -32
- package/src/version.ts +0 -13
package/src/routines/delivery.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Routine } from "./schema.ts";
|
|
2
|
+
import { webhookName } from "./schema.ts";
|
|
2
3
|
import { writeRoutineOutput, addNotice } from "./store.ts";
|
|
3
4
|
|
|
4
5
|
// A relay pusher, injected by the daemon. Given the finished result it either
|
|
@@ -7,13 +8,39 @@ import { writeRoutineOutput, addNotice } from "./store.ts";
|
|
|
7
8
|
// result is durably accounted for, so delivery doesn't add a notice backstop for it.
|
|
8
9
|
export type RelayPusher = (routine: Routine, content: string) => "live" | "queued";
|
|
9
10
|
|
|
11
|
+
// A cloud-outbox pusher, injected by the daemon. Seals the result to the account's
|
|
12
|
+
// outbox public key and POSTs the ciphertext to the server (E2EE store-and-forward),
|
|
13
|
+
// or buffers it durably on failure. Returns "sent" when the server accepted it,
|
|
14
|
+
// "queued" when it was persisted for a later flush — either way the result is
|
|
15
|
+
// durably accounted for. `status` is carried so the sealed envelope can render
|
|
16
|
+
// ok/error without re-parsing the markdown body.
|
|
17
|
+
export type CloudPusher = (routine: Routine, content: string, status: "ok" | "error") => Promise<"sent" | "queued">;
|
|
18
|
+
|
|
19
|
+
// A named webhook endpoint from config `webhooks`.
|
|
20
|
+
export interface WebhookTarget {
|
|
21
|
+
url: string;
|
|
22
|
+
format?: "slack" | "discord" | "json";
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
export interface DeliveryContext {
|
|
11
26
|
pushRelay?: RelayPusher;
|
|
27
|
+
// Seals + posts the result to the account's cloud outbox, or buffers it. Absent
|
|
28
|
+
// (e.g. not signed in) → the `cloud` channel falls back to a notice so the result
|
|
29
|
+
// isn't silently lost.
|
|
30
|
+
pushCloud?: CloudPusher;
|
|
31
|
+
// Named endpoints for "webhook:<name>" delivery entries; results POST here.
|
|
32
|
+
webhooks?: Record<string, WebhookTarget>;
|
|
33
|
+
// Scrubs secrets from anything leaving the machine. Webhook bodies always pass
|
|
34
|
+
// through this when provided (the daemon wires in redactText).
|
|
35
|
+
redact?: (text: string) => string;
|
|
36
|
+
// Injectable for tests; defaults to global fetch.
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
12
38
|
}
|
|
13
39
|
|
|
14
40
|
export interface DeliveryReport {
|
|
15
41
|
// Channels that actually delivered (email is handled inside the agent run, so it
|
|
16
|
-
// never appears here — see the daemon).
|
|
42
|
+
// never appears here — see the daemon). Failed webhooks show as
|
|
43
|
+
// "webhook:<name>(failed)" and leave a notice so the result isn't silently lost.
|
|
17
44
|
delivered: string[];
|
|
18
45
|
// Absolute path to latest.md when file delivery ran.
|
|
19
46
|
filePath?: string;
|
|
@@ -23,19 +50,65 @@ function previewOf(content: string): string {
|
|
|
23
50
|
return content.replace(/\s+/g, " ").trim().slice(0, 120) || "(no output)";
|
|
24
51
|
}
|
|
25
52
|
|
|
53
|
+
// Message-size caps per format (Discord rejects >2000 chars of content; Slack
|
|
54
|
+
// truncates around 40k; plain JSON consumers get a generous bound).
|
|
55
|
+
const FORMAT_CAPS = { slack: 39_000, discord: 1_900, json: 100_000 } as const;
|
|
56
|
+
|
|
57
|
+
function truncate(text: string, cap: number): string {
|
|
58
|
+
return text.length <= cap ? text : text.slice(0, cap - 12) + "\n…truncated";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Wrap the result for the target service. All formats carry plain text — no
|
|
62
|
+
// service-specific rich blocks, so a generic receiver can consume `json` too.
|
|
63
|
+
export function webhookBody(
|
|
64
|
+
target: WebhookTarget,
|
|
65
|
+
routine: Pick<Routine, "name">,
|
|
66
|
+
content: string,
|
|
67
|
+
status: "ok" | "error",
|
|
68
|
+
): string {
|
|
69
|
+
const format = target.format ?? "json";
|
|
70
|
+
const text = truncate(content, FORMAT_CAPS[format]);
|
|
71
|
+
switch (format) {
|
|
72
|
+
case "slack":
|
|
73
|
+
return JSON.stringify({ text: `*${routine.name}* (${status})\n${text}` });
|
|
74
|
+
case "discord":
|
|
75
|
+
return JSON.stringify({ content: `**${routine.name}** (${status})\n${text}` });
|
|
76
|
+
case "json":
|
|
77
|
+
return JSON.stringify({ routine: routine.name, status, at: new Date().toISOString(), content: text });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function postWebhook(
|
|
82
|
+
target: WebhookTarget,
|
|
83
|
+
routine: Routine,
|
|
84
|
+
content: string,
|
|
85
|
+
status: "ok" | "error",
|
|
86
|
+
fetchImpl: typeof fetch,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const res = await fetchImpl(target.url, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "content-type": "application/json" },
|
|
91
|
+
body: webhookBody(target, routine, content, status),
|
|
92
|
+
signal: AbortSignal.timeout(15_000),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
26
97
|
// Deliver a routine's result to its configured channels. `file` and `notice` are
|
|
27
98
|
// deterministic and on-box. `relay` pushes to an attached controller in real time
|
|
28
99
|
// (best-effort — the socket may be up with no controller attached), so we ALSO keep
|
|
29
100
|
// a durable record when the routine has no other on-box channel, guaranteeing the
|
|
30
|
-
// result is never silently lost. `
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
101
|
+
// result is never silently lost. `webhook:<name>` POSTs the (redacted) result to a
|
|
102
|
+
// named endpoint from config; a failed or unconfigured webhook leaves a notice.
|
|
103
|
+
// `email` is intentionally not handled here: it is fulfilled inside the agent turn
|
|
104
|
+
// (the daemon adds the Gmail tool + an instruction to the prompt) so plaintext
|
|
105
|
+
// egress stays an explicit, gated action.
|
|
106
|
+
export async function deliver(
|
|
34
107
|
routine: Routine,
|
|
35
108
|
content: string,
|
|
36
109
|
status: "ok" | "error",
|
|
37
110
|
ctx: DeliveryContext = {},
|
|
38
|
-
): DeliveryReport {
|
|
111
|
+
): Promise<DeliveryReport> {
|
|
39
112
|
const delivered: string[] = [];
|
|
40
113
|
const wants = new Set(routine.delivery);
|
|
41
114
|
let filePath: string | undefined;
|
|
@@ -57,15 +130,51 @@ export function deliver(
|
|
|
57
130
|
// next attaches (the daemon persists that queue, so it's durable either way). Only
|
|
58
131
|
// when no pusher is wired at all do we fall back to a notice so it isn't lost.
|
|
59
132
|
if (wants.has("relay")) {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
else if (
|
|
133
|
+
const pushed = ctx.pushRelay?.(routine, content);
|
|
134
|
+
if (pushed === "live") delivered.push("relay");
|
|
135
|
+
else if (pushed === "queued") delivered.push("relay(queued)");
|
|
63
136
|
else if (!wants.has("file") && !wants.has("notice")) {
|
|
64
137
|
leaveNotice();
|
|
65
138
|
delivered.push("notice(backstop)");
|
|
66
139
|
}
|
|
67
140
|
}
|
|
68
141
|
|
|
142
|
+
// Cloud outbox: sealed E2EE store-and-forward. "sent" reached the server; "queued"
|
|
143
|
+
// was buffered on disk (offline / server down / app hasn't published its outbox key
|
|
144
|
+
// yet) and flushes later — both are durable. Only when no pusher is wired at all,
|
|
145
|
+
// and nothing else keeps a durable record, do we fall back to a notice.
|
|
146
|
+
if (wants.has("cloud")) {
|
|
147
|
+
const sent = ctx.pushCloud ? await ctx.pushCloud(routine, content, status) : undefined;
|
|
148
|
+
if (sent === "sent") delivered.push("cloud");
|
|
149
|
+
else if (sent === "queued") delivered.push("cloud(queued)");
|
|
150
|
+
else if (!wants.has("file") && !wants.has("notice") && !wants.has("relay")) {
|
|
151
|
+
leaveNotice();
|
|
152
|
+
delivered.push("notice(backstop)");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Webhooks: plaintext leaves the machine, so the body is always redacted when a
|
|
157
|
+
// scrubber is wired. Any failure (unconfigured name, HTTP error, timeout) leaves
|
|
158
|
+
// a notice so the user learns the result existed and the push didn't happen.
|
|
159
|
+
for (const entry of routine.delivery) {
|
|
160
|
+
const name = webhookName(entry);
|
|
161
|
+
if (!name) continue;
|
|
162
|
+
const target = ctx.webhooks?.[name];
|
|
163
|
+
if (!target) {
|
|
164
|
+
leaveNotice();
|
|
165
|
+
delivered.push(`${entry}(unconfigured)`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const body = ctx.redact ? ctx.redact(content) : content;
|
|
170
|
+
await postWebhook(target, routine, body, status, ctx.fetchImpl ?? fetch);
|
|
171
|
+
delivered.push(entry);
|
|
172
|
+
} catch {
|
|
173
|
+
leaveNotice();
|
|
174
|
+
delivered.push(`${entry}(failed)`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
69
178
|
if (wants.has("notice")) {
|
|
70
179
|
leaveNotice();
|
|
71
180
|
delivered.push("notice");
|
package/src/routines/schema.ts
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
// Where a routine's result is delivered after it runs.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// Where a routine's result is delivered after it runs. `file`/`relay`/`notice` stay
|
|
4
|
+
// inside the user's trust boundary. `cloud` stores an END-TO-END-ENCRYPTED copy in
|
|
5
|
+
// the account's server outbox for the app to catch up on when it next opens (the
|
|
6
|
+
// server only ever sees ciphertext — it can't read results — but does learn that a
|
|
7
|
+
// result exists, plus its time and rough size). `email` and `webhook:<name>` cross
|
|
8
|
+
// the boundary in plaintext to a third-party service, so they are opt-in and labeled
|
|
9
|
+
// at approval time.
|
|
10
|
+
export const DELIVERY_CHANNELS = ["file", "relay", "notice", "cloud", "email"] as const;
|
|
7
11
|
export type DeliveryChannel = (typeof DELIVERY_CHANNELS)[number];
|
|
8
12
|
|
|
13
|
+
// A webhook entry references a named endpoint from config `webhooks` — the routine
|
|
14
|
+
// itself never carries a URL, so all egress targets stay in one reviewable place.
|
|
15
|
+
const WEBHOOK_ENTRY_RE = /^webhook:[a-zA-Z0-9._-]{1,64}$/;
|
|
16
|
+
|
|
17
|
+
export const DeliveryEntry = z.union([
|
|
18
|
+
z.enum(DELIVERY_CHANNELS),
|
|
19
|
+
z.string().regex(WEBHOOK_ENTRY_RE, "webhook entries look like 'webhook:<config-name>'"),
|
|
20
|
+
]);
|
|
21
|
+
export type DeliveryEntry = z.infer<typeof DeliveryEntry>;
|
|
22
|
+
|
|
23
|
+
// The webhook name of a delivery entry, or null for the builtin channels.
|
|
24
|
+
export function webhookName(entry: string): string | null {
|
|
25
|
+
return entry.startsWith("webhook:") ? entry.slice("webhook:".length) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
// A saved, unattended agent task. Persisted in routines.json and executed by the
|
|
10
29
|
// daemon when its trigger comes due. A routine's trigger is EITHER recurring (a
|
|
11
30
|
// cron expression) or one-off (`at`, a specific datetime) — exactly one is set.
|
|
@@ -27,7 +46,7 @@ export const Routine = z
|
|
|
27
46
|
// Optional "provider:model" override; falls back to config.defaultModel.
|
|
28
47
|
model: z.string().optional(),
|
|
29
48
|
// Where to deliver the result. Defaults to on-box file output.
|
|
30
|
-
delivery: z.array(
|
|
49
|
+
delivery: z.array(DeliveryEntry).default(["file"]),
|
|
31
50
|
// Optional tool allow-subset. Unset → the safe read/web set (see daemon). Entries
|
|
32
51
|
// may be builtin names ("read") or MCP selectors — "<server>__<tool>" exact or
|
|
33
52
|
// "<server>__*" for a whole server (see routines/toolSelect.ts). Selected MCP
|
package/src/routines/store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { globalDir } from "../config/
|
|
4
|
+
import { globalDir } from "../config/paths.ts";
|
|
5
5
|
import { Routine, RoutineFile } from "./schema.ts";
|
|
6
6
|
|
|
7
7
|
// routines.json lives alongside config.json in the global dir. It can carry the
|
|
@@ -203,3 +203,46 @@ export function drainPendingRelay(): PendingRelay[] {
|
|
|
203
203
|
}
|
|
204
204
|
return queue;
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
// A `cloud`-delivery result that couldn't be sealed+posted to the account outbox
|
|
208
|
+
// yet (offline, server down, or the app hasn't published its outbox key). Held on
|
|
209
|
+
// disk until a later flush succeeds. Unlike PendingRelay this carries `status`, so
|
|
210
|
+
// the sealed envelope the app opens can render ok/error without re-parsing markdown.
|
|
211
|
+
export interface PendingCloud {
|
|
212
|
+
routine: string;
|
|
213
|
+
at: string; // ISO timestamp
|
|
214
|
+
status: "ok" | "error";
|
|
215
|
+
content: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pendingCloudPath(): string {
|
|
219
|
+
return join(globalDir(), "routines", "pending-cloud.json");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function loadPendingCloud(): PendingCloud[] {
|
|
223
|
+
const path = pendingCloudPath();
|
|
224
|
+
if (!existsSync(path)) return [];
|
|
225
|
+
try {
|
|
226
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
227
|
+
return Array.isArray(data) ? (data as PendingCloud[]) : [];
|
|
228
|
+
} catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function addPendingCloud(entry: PendingCloud): void {
|
|
234
|
+
const dir = join(globalDir(), "routines");
|
|
235
|
+
mkdirSync(dir, { recursive: true });
|
|
236
|
+
const queue = loadPendingCloud();
|
|
237
|
+
queue.push(entry);
|
|
238
|
+
savePendingCloud(queue.slice(-50)); // bound the backlog
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Overwrite the queue wholesale — used by the flush to drop the items it managed
|
|
242
|
+
// to post while keeping the ones that still failed (and their order).
|
|
243
|
+
export function savePendingCloud(entries: PendingCloud[]): void {
|
|
244
|
+
const dir = join(globalDir(), "routines");
|
|
245
|
+
mkdirSync(dir, { recursive: true });
|
|
246
|
+
writeFileSync(pendingCloudPath(), JSON.stringify(entries, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
247
|
+
tryChmod(pendingCloudPath(), 0o600);
|
|
248
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
// A record of tool-name → tool definition. Was `ai`'s ToolSet in 0.2; MCP tool
|
|
2
|
+
// wiring is a Phase-5 concern, so a structural type suffices for the filter below.
|
|
3
|
+
type ToolSet = Record<string, unknown>;
|
|
2
4
|
|
|
3
5
|
// A routine's `tools` field mixes builtin tool names with MCP selectors. MCP tools
|
|
4
6
|
// are namespaced "<server>__<tool>" (see adaptMcpTools), and no builtin name contains
|
package/src/session.ts
CHANGED
|
@@ -1,289 +1,97 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
// Thin headless session wrapper (Phase 1).
|
|
2
|
+
//
|
|
3
|
+
// Wraps Pi's createAgentSession and exposes the ONE thing the preserved
|
|
4
|
+
// connection layer needs: a subscription that yields privateer EngineEvents
|
|
5
|
+
// (via the adapter) instead of raw Pi events. `createSession()` →
|
|
6
|
+
// `{ session, subscribeAsEngineEvents() }`, per docs/pi-migration-plan.md §2
|
|
7
|
+
// Phase 1. Mirrors the setup proven in ../../ pi-spike/spike-b.mjs.
|
|
8
|
+
//
|
|
9
|
+
// This module is Pi-touching: it is only ever loaded via a DYNAMIC import from
|
|
10
|
+
// an entrypoint that has already run ./boot.ts (env + dispatcher). Never import
|
|
11
|
+
// it statically from boot.ts or anything boot pulls in.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createAgentSession,
|
|
15
|
+
DefaultResourceLoader,
|
|
16
|
+
SessionManager,
|
|
17
|
+
SettingsManager,
|
|
18
|
+
ModelRegistry,
|
|
19
|
+
AuthStorage,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
|
|
22
|
+
import { agentDir as defaultAgentDir } from "./config/paths.ts";
|
|
23
|
+
import { createEngineEventAdapter } from "./bridge/engineAdapter.ts";
|
|
24
|
+
import type { EngineEvent } from "./engine/events.ts";
|
|
25
|
+
|
|
26
|
+
export interface CreateSessionOptions {
|
|
23
27
|
cwd: string;
|
|
24
|
-
gate?: PermissionGate;
|
|
25
|
-
// Confine file tools to cwd (default true). False lets the agent read/edit anywhere.
|
|
26
|
-
confineToCwd?: boolean;
|
|
27
|
-
// Out-of-cwd directories approved this session; shared with the gate so an approved
|
|
28
|
-
// location stops re-prompting. Pass the same array instance the gate holds.
|
|
29
|
-
allowedOutsideRoots?: string[];
|
|
30
|
-
// Active output style name (persona); resolved against .privateer/output-styles.
|
|
31
|
-
outputStyle?: string;
|
|
32
|
-
// When true, the system prompt instructs the model to plan, not implement.
|
|
33
|
-
planMode?: boolean;
|
|
34
|
-
// Session checkpoint store; write/edit record mutations into it for /rewind.
|
|
35
|
-
checkpoints?: CheckpointStore;
|
|
36
|
-
// Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
|
|
37
|
-
extraTools?: ToolSet;
|
|
38
|
-
// When set, restrict the built-in toolset to these tool names (MCP `extraTools`
|
|
39
|
-
// are always kept). Used for unattended runs (scheduled routines) that must not
|
|
40
|
-
// have write/bash/edit auto-approved with no human to gate them.
|
|
41
|
-
allowedTools?: string[];
|
|
42
|
-
// Background-shell registry for bash run_in_background / bash_output / kill_shell.
|
|
43
|
-
processes?: ProcessRegistry;
|
|
44
|
-
// Session attachment store, so dragged/pasted file bytes can be saved via the
|
|
45
|
-
// save_attachment tool. Created here when the caller doesn't supply one.
|
|
46
|
-
attachments?: AttachmentStore;
|
|
47
|
-
// Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
|
|
48
|
-
// tool-call id, so the TUI can render the grouped agents view. Best-effort.
|
|
49
|
-
onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
|
|
50
|
-
// Surfaces an `ask_user` question to the live TUI and resolves with the choice.
|
|
51
|
-
// Omitted outside the interactive app, where ask_user reports it couldn't ask.
|
|
52
|
-
askUser?: UserAsker;
|
|
53
|
-
// Streams a file to the connected remote controller (Privateer app), for the
|
|
54
|
-
// send_file_to_client tool. Omitted when remote access isn't available.
|
|
55
|
-
sendFileToController?: (file: {
|
|
56
|
-
name: string;
|
|
57
|
-
mediaType: string;
|
|
58
|
-
base64: string;
|
|
59
|
-
size: number;
|
|
60
|
-
}) => Promise<{ ok: boolean; reason?: string }>;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface Session {
|
|
64
|
-
engine: QueryEngine;
|
|
65
|
-
modelSpec: string;
|
|
66
28
|
provider: string;
|
|
67
29
|
modelId: string;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
30
|
+
// Pi extension factories, e.g. [makePermissionGate({ decide })].
|
|
31
|
+
extensionFactories?: Array<(pi: any) => void>;
|
|
32
|
+
// Custom tools (defineTool) plus the enabled tool-name allowlist.
|
|
33
|
+
customTools?: unknown[];
|
|
34
|
+
tools?: string[];
|
|
35
|
+
// Override the Pi agent dir; defaults to $PRIVATEER_HOME/agent (pinned by boot).
|
|
36
|
+
agentDir?: string;
|
|
71
37
|
}
|
|
72
38
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const allowedOutsideRoots = opts.allowedOutsideRoots ?? [];
|
|
80
|
-
const todos = new TodoStore();
|
|
81
|
-
const attachments = opts.attachments ?? new AttachmentStore();
|
|
82
|
-
const cache = isAnthropicFamily(resolved.provider, resolved.modelId);
|
|
83
|
-
|
|
84
|
-
// Bound how many sub-agents run at once when the model fans `task` calls out.
|
|
85
|
-
const subAgentLimit = createLimiter(opts.config.maxSubagents);
|
|
39
|
+
export interface PrivateerSession {
|
|
40
|
+
session: any;
|
|
41
|
+
// Subscribe to the turn stream as privateer EngineEvents. Returns an unsubscribe.
|
|
42
|
+
subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void;
|
|
43
|
+
adapter: ReturnType<typeof createEngineEventAdapter>;
|
|
44
|
+
}
|
|
86
45
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// auto-approve gate; with one it uses that agent's tools (routed through the parent
|
|
90
|
-
// gate, so any mutations are still user-approved), model override, and instructions.
|
|
91
|
-
const runSubAgent: SubAgentRunner = ({ description, prompt, agent }) =>
|
|
92
|
-
subAgentLimit(async () => {
|
|
93
|
-
let model = resolved.model;
|
|
94
|
-
let childCache = cache;
|
|
95
|
-
if (agent?.model) {
|
|
96
|
-
try {
|
|
97
|
-
const r = resolveModel(agent.model, opts.config);
|
|
98
|
-
model = r.model;
|
|
99
|
-
childCache = isAnthropicFamily(r.provider, r.modelId);
|
|
100
|
-
} catch {
|
|
101
|
-
/* fall back to the parent model */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
const system = agent
|
|
105
|
-
? buildAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description, instructions: agent.prompt })
|
|
106
|
-
: buildSubAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description });
|
|
107
|
-
const tools = agent
|
|
108
|
-
? createToolSubset({ cwd: opts.cwd, gate, confineToCwd, allowedOutsideRoots }, agent.tools)
|
|
109
|
-
: createReadOnlyTools({ cwd: opts.cwd, gate: autoApproveGate, confineToCwd, allowedOutsideRoots });
|
|
46
|
+
export async function createSession(opts: CreateSessionOptions): Promise<PrivateerSession> {
|
|
47
|
+
const AGENT_DIR = opts.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? defaultAgentDir();
|
|
110
48
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
});
|
|
117
|
-
let out = "";
|
|
118
|
-
let toolUses = 0;
|
|
119
|
-
for await (const ev of child.send(prompt)) {
|
|
120
|
-
if (ev.type === "text") out += ev.text;
|
|
121
|
-
else if (ev.type === "tool-call") toolUses++;
|
|
122
|
-
else if (ev.type === "error")
|
|
123
|
-
return { text: `Sub-agent error: ${ev.error}`, toolUses, tokens: child.usage.totalTokens };
|
|
124
|
-
}
|
|
125
|
-
return {
|
|
126
|
-
text: out.trim() || "(sub-agent returned no output)",
|
|
127
|
-
toolUses,
|
|
128
|
-
tokens: child.usage.totalTokens,
|
|
129
|
-
};
|
|
130
|
-
});
|
|
49
|
+
const authStorage = AuthStorage.inMemory();
|
|
50
|
+
const modelRegistry = ModelRegistry.create(authStorage, `${AGENT_DIR}/models.json`);
|
|
51
|
+
if (typeof (modelRegistry as any).refresh === "function") await (modelRegistry as any).refresh();
|
|
52
|
+
else if (typeof (modelRegistry as any).loadModels === "function")
|
|
53
|
+
await (modelRegistry as any).loadModels();
|
|
131
54
|
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
runSubAgent,
|
|
140
|
-
onSubAgentMetrics: opts.onSubAgentMetrics,
|
|
141
|
-
recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
|
|
142
|
-
processes: opts.processes,
|
|
143
|
-
attachments,
|
|
144
|
-
askUser: opts.askUser,
|
|
145
|
-
sendFileToController: opts.sendFileToController,
|
|
146
|
-
});
|
|
147
|
-
if (opts.allowedTools) {
|
|
148
|
-
const allow = new Set(opts.allowedTools);
|
|
149
|
-
builtinTools = Object.fromEntries(
|
|
150
|
-
Object.entries(builtinTools).filter(([name]) => allow.has(name)),
|
|
55
|
+
const model = modelRegistry.find(opts.provider, opts.modelId);
|
|
56
|
+
if (!model) {
|
|
57
|
+
const ids = ((modelRegistry as any).getAll?.() ?? []).map(
|
|
58
|
+
(m: any) => `${m.provider}/${m.id}`,
|
|
59
|
+
);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`model ${opts.provider}/${opts.modelId} not found. Registry has: ${ids.join(", ") || "(none)"}`,
|
|
151
62
|
);
|
|
152
63
|
}
|
|
153
|
-
const tools = wrapToolsWithHooks(
|
|
154
|
-
{
|
|
155
|
-
...builtinTools,
|
|
156
|
-
...(opts.extraTools ?? {}),
|
|
157
|
-
},
|
|
158
|
-
hooks,
|
|
159
|
-
);
|
|
160
|
-
const outputStyleBody = opts.outputStyle
|
|
161
|
-
? findOutputStyle(opts.outputStyle, opts.cwd)?.body
|
|
162
|
-
: undefined;
|
|
163
|
-
const system = buildSystemPrompt({
|
|
164
|
-
cwd: opts.cwd,
|
|
165
|
-
model: opts.modelSpec,
|
|
166
|
-
outputStyleBody,
|
|
167
|
-
planMode: opts.planMode,
|
|
168
|
-
});
|
|
169
64
|
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
compactRatio: opts.config.compactRatio,
|
|
65
|
+
const settingsManager = SettingsManager.create(opts.cwd, AGENT_DIR);
|
|
66
|
+
const loader = new DefaultResourceLoader({
|
|
67
|
+
cwd: opts.cwd,
|
|
68
|
+
agentDir: AGENT_DIR,
|
|
69
|
+
settingsManager,
|
|
70
|
+
extensionFactories: opts.extensionFactories ?? [],
|
|
177
71
|
});
|
|
72
|
+
await loader.reload();
|
|
178
73
|
|
|
179
|
-
|
|
180
|
-
engine,
|
|
181
|
-
modelSpec: opts.modelSpec,
|
|
182
|
-
provider: resolved.provider,
|
|
183
|
-
modelId: resolved.modelId,
|
|
74
|
+
const { session } = await createAgentSession({
|
|
184
75
|
cwd: opts.cwd,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Resolve a "provider:model" spec into a Route, deriving its per-model cache /
|
|
206
|
-
// thinking flags and supported input modalities from the model family.
|
|
207
|
-
function buildRoute(spec: string, config: Config): Route {
|
|
208
|
-
const r = resolveModel(spec, config);
|
|
209
|
-
const cache = isAnthropicFamily(r.provider, r.modelId);
|
|
210
|
-
return {
|
|
211
|
-
spec,
|
|
212
|
-
model: r.model,
|
|
213
|
-
cacheControl: cache,
|
|
214
|
-
thinkingBudget: cache ? config.thinkingBudget : undefined,
|
|
215
|
-
label: shortLabel(spec),
|
|
216
|
-
supports: modalitiesFor(r.provider, r.modelId),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// A trivial RouteSet with only the default route (sub-agents, which run one fixed
|
|
221
|
-
// model). The high `longThreshold` / zero `fastMaxChars` keep the router on default.
|
|
222
|
-
function singleRouteSet(spec: string, model: LanguageModel, cacheControl: boolean): RouteSet {
|
|
223
|
-
const { provider, modelId } = parseModelSpec(spec);
|
|
224
|
-
return {
|
|
225
|
-
default: { spec, model, cacheControl, label: shortLabel(spec), supports: modalitiesFor(provider, modelId) },
|
|
226
|
-
longThreshold: Number.POSITIVE_INFINITY,
|
|
227
|
-
fastMaxChars: 0,
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Pairs of (config key, RouteSet key, modality) for the modality routes.
|
|
232
|
-
const MODALITY_ROUTE_KEYS: { cfg: "vision" | "document" | "audio" | "video"; modality: Modality }[] = [
|
|
233
|
-
{ cfg: "vision", modality: "image" },
|
|
234
|
-
{ cfg: "document", modality: "document" },
|
|
235
|
-
{ cfg: "audio", modality: "audio" },
|
|
236
|
-
{ cfg: "video", modality: "video" },
|
|
237
|
-
];
|
|
238
|
-
|
|
239
|
-
// Assemble the session's RouteSet: the default route (the already-resolved session
|
|
240
|
-
// model) plus any configured modality/long/fast routes, each tagged with the input
|
|
241
|
-
// modalities its model accepts. Optional routes that fail to resolve are skipped
|
|
242
|
-
// rather than failing the session. For each modality whose route is unset, hybrid
|
|
243
|
-
// auto-detect picks a capable model when the default can't handle that modality.
|
|
244
|
-
function buildRouteSet(
|
|
245
|
-
config: Config,
|
|
246
|
-
defaultSpec: string,
|
|
247
|
-
defaultModel: LanguageModel,
|
|
248
|
-
defaultCache: boolean,
|
|
249
|
-
): RouteSet {
|
|
250
|
-
const router = config.router;
|
|
251
|
-
const tryRoute = (spec?: string): Route | undefined => {
|
|
252
|
-
if (!spec) return undefined;
|
|
253
|
-
try {
|
|
254
|
-
return buildRoute(spec, config);
|
|
255
|
-
} catch {
|
|
256
|
-
return undefined; // unconfigured/invalid optional route → ignored
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
|
|
260
|
-
const { provider: defProvider, modelId: defModelId } = parseModelSpec(defaultSpec);
|
|
261
|
-
const routes: RouteSet = {
|
|
262
|
-
default: {
|
|
263
|
-
spec: defaultSpec,
|
|
264
|
-
model: defaultModel,
|
|
265
|
-
cacheControl: defaultCache,
|
|
266
|
-
thinkingBudget: defaultCache ? config.thinkingBudget : undefined,
|
|
267
|
-
label: shortLabel(defaultSpec),
|
|
268
|
-
supports: modalitiesFor(defProvider, defModelId),
|
|
269
|
-
},
|
|
270
|
-
vision: tryRoute(router?.vision),
|
|
271
|
-
document: tryRoute(router?.document),
|
|
272
|
-
audio: tryRoute(router?.audio),
|
|
273
|
-
video: tryRoute(router?.video),
|
|
274
|
-
long: tryRoute(router?.long),
|
|
275
|
-
fast: tryRoute(router?.fast),
|
|
276
|
-
longThreshold: router?.longThreshold ?? Math.floor((config.contextBudget ?? 120_000) / 2),
|
|
277
|
-
fastMaxChars: router?.fastMaxChars ?? 280,
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
if (router?.auto ?? true) {
|
|
281
|
-
for (const { cfg, modality } of MODALITY_ROUTE_KEYS) {
|
|
282
|
-
if (routes[cfg]) continue; // explicitly configured → leave it
|
|
283
|
-
if (modelSupports(modality, defProvider, defModelId)) continue; // default handles it
|
|
284
|
-
const suggestion = suggestModelFor(modality, config);
|
|
285
|
-
if (suggestion) routes[cfg] = tryRoute(suggestion);
|
|
286
|
-
}
|
|
76
|
+
agentDir: AGENT_DIR,
|
|
77
|
+
model,
|
|
78
|
+
authStorage,
|
|
79
|
+
modelRegistry,
|
|
80
|
+
settingsManager,
|
|
81
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
82
|
+
resourceLoader: loader,
|
|
83
|
+
...(opts.customTools ? { customTools: opts.customTools } : {}),
|
|
84
|
+
...(opts.tools ? { tools: opts.tools } : {}),
|
|
85
|
+
} as any);
|
|
86
|
+
|
|
87
|
+
const adapter = createEngineEventAdapter();
|
|
88
|
+
|
|
89
|
+
function subscribeAsEngineEvents(onEvent: (ev: EngineEvent) => void): () => void {
|
|
90
|
+
const unsub = session.subscribe((ev: any) => {
|
|
91
|
+
for (const ee of adapter.toEngineEvents(ev)) onEvent(ee);
|
|
92
|
+
});
|
|
93
|
+
return () => unsub?.();
|
|
287
94
|
}
|
|
288
|
-
|
|
95
|
+
|
|
96
|
+
return { session, subscribeAsEngineEvents, adapter };
|
|
289
97
|
}
|