privateer-agent 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +135 -443
  2. package/bin/privateer-tui +86 -0
  3. package/bin/privateer.mjs +17 -6
  4. package/bin/pv +28 -0
  5. package/package.json +26 -23
  6. package/src/auth/privateer.ts +57 -0
  7. package/src/boot.ts +43 -0
  8. package/src/bridge/engineAdapter.ts +182 -0
  9. package/src/cli/chat.ts +243 -0
  10. package/src/config/paths.ts +24 -46
  11. package/src/config/permissionMode.ts +5 -0
  12. package/src/crypto/outboxSeal.ts +61 -0
  13. package/src/daemon/index.ts +204 -121
  14. package/src/daemon/ipc.ts +1 -1
  15. package/src/engine/errors.ts +85 -45
  16. package/src/engine/router.ts +11 -165
  17. package/src/ext/permissionGate.ts +216 -0
  18. package/src/main.ts +32 -0
  19. package/src/permissions/classify.ts +172 -0
  20. package/src/permissions/gate.ts +11 -14
  21. package/src/permissions/mode.ts +6 -2
  22. package/src/permissions/{uiGate.ts → modeGate.ts} +18 -3
  23. package/src/providers/account.ts +170 -0
  24. package/src/providers/catalog.ts +73 -61
  25. package/src/providers/genModelsJson.ts +97 -0
  26. package/src/remote/relayClient.ts +15 -0
  27. package/src/remote/remoteBridge.ts +152 -0
  28. package/src/routines/delivery.ts +118 -9
  29. package/src/routines/schema.ts +24 -5
  30. package/src/routines/store.ts +44 -1
  31. package/src/routines/toolSelect.ts +3 -1
  32. package/src/session.ts +81 -273
  33. package/src/tools/routine.ts +120 -101
  34. package/src/tools/saveAttachment.ts +39 -42
  35. package/src/tools/sendFile.ts +75 -0
  36. package/src/util/attachmentStore.ts +18 -35
  37. package/src/util/redact.ts +33 -3
  38. package/LICENSE +0 -21
  39. package/src/agents/loader.ts +0 -49
  40. package/src/commands/custom.ts +0 -123
  41. package/src/commands/registry.ts +0 -618
  42. package/src/components/AgentGroupView.tsx +0 -104
  43. package/src/components/App.tsx +0 -1626
  44. package/src/components/ApprovalPrompt.tsx +0 -49
  45. package/src/components/Banner.tsx +0 -78
  46. package/src/components/Markdown.tsx +0 -183
  47. package/src/components/ModeHint.tsx +0 -40
  48. package/src/components/ModelPicker.tsx +0 -302
  49. package/src/components/Onboarding.tsx +0 -203
  50. package/src/components/OptionPicker.tsx +0 -134
  51. package/src/components/PlanConfirm.tsx +0 -37
  52. package/src/components/PrivateerLogin.tsx +0 -109
  53. package/src/components/PromptInput.tsx +0 -602
  54. package/src/components/RewindPicker.tsx +0 -69
  55. package/src/components/Root.tsx +0 -116
  56. package/src/components/SessionPicker.tsx +0 -64
  57. package/src/components/StatusBar.tsx +0 -131
  58. package/src/components/TodoPanel.tsx +0 -36
  59. package/src/components/ToolCallView.tsx +0 -113
  60. package/src/components/Transcript.tsx +0 -210
  61. package/src/components/figures.ts +0 -14
  62. package/src/components/promptModel.ts +0 -73
  63. package/src/components/spinnerVerbs.ts +0 -46
  64. package/src/components/theme.ts +0 -57
  65. package/src/components/types.ts +0 -34
  66. package/src/components/useTeeShield.ts +0 -104
  67. package/src/components/useTerminalWidth.ts +0 -24
  68. package/src/components/useZdrShield.ts +0 -126
  69. package/src/config/load.ts +0 -115
  70. package/src/config/schema.ts +0 -94
  71. package/src/context/outputStyles.ts +0 -42
  72. package/src/context/projectInfo.ts +0 -59
  73. package/src/context/systemPrompt.ts +0 -176
  74. package/src/engine/QueryEngine.ts +0 -399
  75. package/src/hooks/engine.ts +0 -155
  76. package/src/main.tsx +0 -209
  77. package/src/mcp/client.ts +0 -251
  78. package/src/mcp/oauth.ts +0 -245
  79. package/src/memory/auto.ts +0 -146
  80. package/src/memory/checkpoints.ts +0 -227
  81. package/src/memory/store.ts +0 -127
  82. package/src/providers/attestation.ts +0 -149
  83. package/src/providers/capabilities.ts +0 -104
  84. package/src/providers/models.ts +0 -183
  85. package/src/providers/registry.ts +0 -71
  86. package/src/providers/resolve.ts +0 -78
  87. package/src/skills/installer.ts +0 -222
  88. package/src/skills/loader.ts +0 -88
  89. package/src/tools/askUser.ts +0 -92
  90. package/src/tools/bash.ts +0 -98
  91. package/src/tools/context.ts +0 -128
  92. package/src/tools/edit.ts +0 -67
  93. package/src/tools/exec.ts +0 -60
  94. package/src/tools/glob.ts +0 -39
  95. package/src/tools/grep.ts +0 -86
  96. package/src/tools/index.ts +0 -83
  97. package/src/tools/memory.ts +0 -53
  98. package/src/tools/processRegistry.ts +0 -77
  99. package/src/tools/read.ts +0 -42
  100. package/src/tools/sendFileToClient.ts +0 -55
  101. package/src/tools/skill.ts +0 -44
  102. package/src/tools/task.ts +0 -52
  103. package/src/tools/todo.ts +0 -36
  104. package/src/tools/todoStore.ts +0 -31
  105. package/src/tools/walk.ts +0 -44
  106. package/src/tools/web.ts +0 -145
  107. package/src/tools/worktree.ts +0 -145
  108. package/src/tools/write.ts +0 -40
  109. package/src/util/images.ts +0 -378
  110. package/src/util/limit.ts +0 -32
  111. package/src/version.ts +0 -13
@@ -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. `email` is intentionally not handled here: it is
31
- // fulfilled inside the agent turn (the daemon adds the Gmail tool + an instruction
32
- // to the prompt) so plaintext egress stays an explicit, gated action.
33
- export function deliver(
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 status = ctx.pushRelay?.(routine, content);
61
- if (status === "live") delivered.push("relay");
62
- else if (status === "queued") delivered.push("relay(queued)");
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");
@@ -1,11 +1,30 @@
1
1
  import { z } from "zod";
2
2
 
3
- // Where a routine's result is delivered after it runs. Everything except `email`
4
- // stays inside the user's trust boundary; `email` crosses it (hands plaintext to a
5
- // third-party mail provider via the Gmail MCP tool), so it is opt-in and labeled.
6
- export const DELIVERY_CHANNELS = ["file", "relay", "notice", "email"] as const;
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(z.enum(DELIVERY_CHANNELS)).default(["file"]),
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
@@ -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/load.ts";
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
- import type { ToolSet } from "ai";
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
- import type { LanguageModel, ToolSet } from "ai";
2
- import type { Config } from "./config/schema.ts";
3
- import { resolveModel, parseModelSpec } from "./providers/resolve.ts";
4
- import { modelSupports, modalitiesFor, suggestModelFor } from "./providers/capabilities.ts";
5
- import type { Route, RouteSet, Modality } from "./engine/router.ts";
6
- import { createTools, createReadOnlyTools, createToolSubset } from "./tools/index.ts";
7
- import { buildSystemPrompt, buildSubAgentPrompt, buildAgentPrompt } from "./context/systemPrompt.ts";
8
- import { findOutputStyle } from "./context/outputStyles.ts";
9
- import { QueryEngine } from "./engine/QueryEngine.ts";
10
- import { autoApproveGate, type PermissionGate } from "./permissions/gate.ts";
11
- import type { SubAgentRunner } from "./tools/context.ts";
12
- import type { UserAsker } from "./tools/askUser.ts";
13
- import { TodoStore } from "./tools/todoStore.ts";
14
- import type { CheckpointStore } from "./memory/checkpoints.ts";
15
- import type { ProcessRegistry } from "./tools/processRegistry.ts";
16
- import { AttachmentStore } from "./util/attachmentStore.ts";
17
- import { HookRunner, loadHooks, wrapToolsWithHooks } from "./hooks/engine.ts";
18
- import { createLimiter } from "./util/limit.ts";
19
-
20
- export interface SessionOptions {
21
- config: Config;
22
- modelSpec: string;
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
- cwd: string;
69
- todos: TodoStore;
70
- attachments: AttachmentStore;
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
- // Assemble a ready-to-run agent session: resolve the model, bind tools to the
74
- // cwd + permission gate, build the system prompt, and create the engine.
75
- export function createSession(opts: SessionOptions): Session {
76
- const resolved = resolveModel(opts.modelSpec, opts.config);
77
- const gate = opts.gate ?? autoApproveGate;
78
- const confineToCwd = opts.confineToCwd ?? true;
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
- // A `task` sub-agent: a fresh engine run to completion, returning the text it
88
- // produced. Without an agent definition it uses the read-only toolset under an
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
- const child = new QueryEngine({
112
- routes: singleRouteSet(agent?.model ?? opts.modelSpec, model, childCache),
113
- system,
114
- tools,
115
- maxSteps: Math.min(opts.config.maxSteps, 20),
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 hooks = new HookRunner(loadHooks((opts.config as Record<string, unknown>).hooks), opts.cwd);
133
- let builtinTools = createTools({
134
- cwd: opts.cwd,
135
- gate,
136
- confineToCwd,
137
- allowedOutsideRoots,
138
- todos,
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 engine = new QueryEngine({
171
- routes: buildRouteSet(opts.config, opts.modelSpec, resolved.model, cache),
172
- system,
173
- tools,
174
- maxSteps: opts.config.maxSteps,
175
- contextBudget: opts.config.contextBudget,
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
- return {
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
- todos,
186
- attachments,
187
- };
188
- }
189
-
190
- // Anthropic prompt caching only benefits Anthropic-family models: direct Anthropic,
191
- // or an OpenRouter route to an Anthropic model. For everything else the cache hints
192
- // are a harmless no-op, but we skip them to avoid sending unused providerOptions.
193
- function isAnthropicFamily(provider: string, modelId: string): boolean {
194
- if (provider === "anthropic") return true;
195
- if (provider === "openrouter") return modelId.startsWith("anthropic/");
196
- return false;
197
- }
198
-
199
- // Short display name for UI notices: drop any "vendor/" prefix from the model id.
200
- function shortLabel(spec: string): string {
201
- const modelId = spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
202
- return modelId.slice(modelId.lastIndexOf("/") + 1);
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
- return routes;
95
+
96
+ return { session, subscribeAsEngineEvents, adapter };
289
97
  }