privateer-agent 0.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Remote-access relay client (Phase 2).
3
+ *
4
+ * When the user enables /remote-access, the running TUI opens an outbound
5
+ * WebSocket to the Privateer server's relay. The app ("controller") can then
6
+ * drive THIS terminal ("agent"): it sends prompts down, and we stream the
7
+ * engine's events + tool-approval requests back up. Tool execution still runs
8
+ * locally and stays gated — a remote-driven turn relays every would-be action
9
+ * to the app for Allow/Deny (see uiGate.ts getRemote).
10
+ *
11
+ * The socket is authenticated with a single-use, short-TTL ticket minted over
12
+ * the authenticated REST channel (RN can't set WS headers and a JWT in the URL
13
+ * would leak). We never carry the JWT into the WS URL.
14
+ *
15
+ * Framework-agnostic: nothing here imports React. The App owns an instance via
16
+ * a ref and wires the callbacks to its turn loop.
17
+ */
18
+ import WebSocket from "ws";
19
+ import { randomUUID } from "node:crypto";
20
+ import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
21
+ import type { EngineEvent } from "../engine/events.ts";
22
+ import type { PermissionRequest } from "../permissions/gate.ts";
23
+
24
+ // Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
25
+ // username@hostname or the working-directory name to the server/controller (the
26
+ // server is supposed to learn as little as possible). A short random tag lets the
27
+ // user tell multiple terminals apart; they can rename it in the app.
28
+ function terminalLabel(): string {
29
+ return `terminal-${randomUUID().slice(0, 4)}`;
30
+ }
31
+
32
+ // Best-effort redaction of secret-looking content before it crosses the relay to
33
+ // the controller/server. This is a SAFETY NET, not a guarantee — truncation
34
+ // bounds size, this bounds obvious secret leakage (bearer tokens, API keys, env
35
+ // secrets, PEM private keys). The "output may contain secrets" warning still
36
+ // stands; a determined leak (unusual formats) can slip through.
37
+ function redactSecrets(s: string): string {
38
+ if (!s) return s;
39
+ return s
40
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted private key]")
41
+ .replace(/\b(bearer)\s+[A-Za-z0-9._\-]{12,}/gi, "$1 [redacted]")
42
+ .replace(/\b(sk|rk|pk|ghp|gho|ghs|github_pat|AKIA|ASIA)[-_][A-Za-z0-9]{8,}/g, "[redacted key]")
43
+ .replace(/\b([A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE)[A-Z0-9_]*)\s*[:=]\s*['"]?[^\s'"]{6,}/gi, "$1=[redacted]");
44
+ }
45
+
46
+ // Redact then clip: redaction runs on full text so a secret near the cut isn't
47
+ // missed, then we bound the wire size.
48
+ function safe(s: string, max: number): string {
49
+ return clip(redactSecrets(s), max);
50
+ }
51
+
52
+ export interface RelayCallbacks {
53
+ // A prompt arrived from the app — feed it into the turn loop (tagged remote).
54
+ onPrompt: (text: string) => void;
55
+ // The app asked to interrupt the in-flight turn.
56
+ onInterrupt: () => void;
57
+ // The app answered a relayed approval request.
58
+ onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
59
+ // A controller attached — push a transcript snapshot so it can catch up.
60
+ onControllerAttached: () => void;
61
+ // Surface a one-line status/notice in the TUI.
62
+ onStatus?: (text: string) => void;
63
+ }
64
+
65
+ const RECONNECT_MS = 3000;
66
+ // Coalesce streaming deltas so we don't emit one WS frame per token.
67
+ const TEXT_FLUSH_MS = 60;
68
+
69
+ function clip(s: string, max: number): string {
70
+ if (s.length <= max) return s;
71
+ return s.slice(0, max) + `\n… (${s.length - max} more chars)`;
72
+ }
73
+
74
+ function asText(output: unknown): string {
75
+ return typeof output === "string" ? output : JSON.stringify(output);
76
+ }
77
+
78
+ // A relay-specific projection of an EngineEvent: serializable + size-bounded.
79
+ // The raw event's tool input/output are `unknown` and bash output can be ~30k
80
+ // chars, so we normalize to text and truncate before sending over the wire.
81
+ function projectEvent(ev: EngineEvent): Record<string, unknown> {
82
+ switch (ev.type) {
83
+ case "tool-call":
84
+ return { type: "tool-call", id: ev.id, name: ev.name, input: safe(asText(ev.input), 2000) };
85
+ case "tool-result":
86
+ return { type: "tool-result", id: ev.id, name: ev.name, output: safe(asText(ev.output), 4000) };
87
+ case "tool-error":
88
+ return { type: "tool-error", id: ev.id, name: ev.name, error: safe(ev.error, 2000) };
89
+ case "usage":
90
+ return { type: "usage", usage: ev.usage, turn: ev.turn };
91
+ case "finish":
92
+ return { type: "finish", finishReason: ev.finishReason };
93
+ case "routed":
94
+ return { type: "routed", label: ev.label, reason: ev.reason };
95
+ case "retrying":
96
+ return { type: "retrying", attempt: ev.attempt, max: ev.max, reason: clip(ev.reason, 500) };
97
+ case "error":
98
+ return { type: "error", error: clip(ev.error, 2000), hint: ev.hint };
99
+ case "compacted":
100
+ return { type: "compacted", before: ev.before, after: ev.after };
101
+ case "aborted":
102
+ return { type: "aborted" };
103
+ case "step-finish":
104
+ return { type: "step-finish" };
105
+ default:
106
+ // text/reasoning are coalesced in sendEvent and never reach here.
107
+ return { type: ev.type };
108
+ }
109
+ }
110
+
111
+ export class RelayClient {
112
+ private ws: WebSocket | null = null;
113
+ private closed = false;
114
+ private connecting = false;
115
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
116
+ // Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
117
+ private bufKind: "text" | "reasoning" | null = null;
118
+ private buf = "";
119
+ private flushTimer: ReturnType<typeof setTimeout> | undefined;
120
+ // Stable for this process so reconnects keep the same terminal identity.
121
+ private readonly termId = randomUUID();
122
+ private readonly label = terminalLabel();
123
+
124
+ constructor(private readonly cb: RelayCallbacks) {}
125
+
126
+ async start(): Promise<void> {
127
+ this.closed = false;
128
+ await this.connect();
129
+ }
130
+
131
+ stop(): void {
132
+ this.closed = true;
133
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
134
+ if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
135
+ this.bufKind = null;
136
+ this.buf = "";
137
+ try { this.ws?.close(); } catch (_) { /* ignore */ }
138
+ this.ws = null;
139
+ }
140
+
141
+ private async connect(): Promise<void> {
142
+ if (this.closed || this.connecting || this.ws) return;
143
+ this.connecting = true;
144
+ try {
145
+ // apiRequest → authedFetch refreshes the JWT on 401, so a single in-flight
146
+ // ticket mint never races a second refresher (we guard with `connecting`).
147
+ const res = await apiRequest("/relay/ticket", {
148
+ method: "POST",
149
+ headers: { "Content-Type": "application/json" },
150
+ body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
151
+ });
152
+ if (!res.ok) throw new Error(`relay ticket HTTP ${res.status}`);
153
+ const { ticket } = (await res.json()) as { ticket: string };
154
+
155
+ const wsUrl =
156
+ serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
157
+ this.debug(`connecting → ${wsUrl}`);
158
+ const ws = new WebSocket(wsUrl);
159
+ this.ws = ws;
160
+ let opened = false;
161
+ let lastErr = "";
162
+
163
+ ws.on("open", () => {
164
+ opened = true;
165
+ this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
166
+ });
167
+ ws.on("message", (data) => this.handle(data));
168
+ ws.on("close", () => {
169
+ if (this.ws === ws) this.ws = null;
170
+ if (!this.closed) {
171
+ this.cb.onStatus?.(
172
+ opened
173
+ ? "Remote access disconnected — reconnecting…"
174
+ : `Remote access couldn't connect${lastErr ? ` (${lastErr})` : ""} — retrying…`,
175
+ );
176
+ }
177
+ this.scheduleReconnect();
178
+ });
179
+ ws.on("error", (err: Error) => {
180
+ // 'close' fires right after and surfaces the reason; just capture it.
181
+ lastErr = err?.message || String(err);
182
+ this.debug(`ws error: ${lastErr}`);
183
+ });
184
+ } catch (err) {
185
+ // Ticket mint failed (auth/network/route) — surface it; a silent failure
186
+ // looks identical to "connected but ignoring me".
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
189
+ this.scheduleReconnect();
190
+ } finally {
191
+ this.connecting = false;
192
+ }
193
+ }
194
+
195
+ private debug(msg: string): void {
196
+ if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
197
+ }
198
+
199
+ private scheduleReconnect(): void {
200
+ if (this.closed || this.reconnectTimer) return;
201
+ this.reconnectTimer = setTimeout(() => {
202
+ this.reconnectTimer = undefined;
203
+ void this.connect();
204
+ }, RECONNECT_MS);
205
+ }
206
+
207
+ private handle(data: WebSocket.RawData): void {
208
+ let frame: { type?: string; text?: string; id?: string; decision?: string };
209
+ try {
210
+ frame = JSON.parse(data.toString());
211
+ } catch (_) {
212
+ return;
213
+ }
214
+ this.debug(`recv ${frame.type}`);
215
+ switch (frame.type) {
216
+ case "prompt":
217
+ if (typeof frame.text === "string" && frame.text.trim()) this.cb.onPrompt(frame.text);
218
+ break;
219
+ case "interrupt":
220
+ this.cb.onInterrupt();
221
+ break;
222
+ case "approval_response":
223
+ if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
224
+ break;
225
+ case "controller_attached":
226
+ this.cb.onControllerAttached();
227
+ break;
228
+ }
229
+ }
230
+
231
+ private rawSend(frame: unknown): void {
232
+ const ws = this.ws;
233
+ if (ws && ws.readyState === WebSocket.OPEN) {
234
+ try { ws.send(JSON.stringify(frame)); } catch (_) { /* socket dying */ }
235
+ }
236
+ }
237
+
238
+ // ── agent → controller ──────────────────────────────────────────────────────
239
+
240
+ sendEvent(ev: EngineEvent): void {
241
+ if (ev.type === "text") return this.bufferDelta("text", ev.text);
242
+ if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
243
+ this.flushDeltas(); // preserve ordering relative to buffered text
244
+ this.rawSend({ type: "event", event: projectEvent(ev) });
245
+ }
246
+
247
+ // Catch-up history sent to a controller on attach. Structured (not markdown) so
248
+ // the app renders it with the same styling as the live feed. Bounded: last 80
249
+ // entries, each clipped.
250
+ sendSnapshot(entries: { kind: string; text: string }[]): void {
251
+ const trimmed = entries.slice(-80).map((e) => ({ kind: e.kind, text: safe(String(e.text ?? ""), 4000) }));
252
+ this.rawSend({ type: "snapshot", entries: trimmed });
253
+ }
254
+
255
+ requestApproval(id: string, req: PermissionRequest): void {
256
+ this.rawSend({
257
+ type: "approval_request",
258
+ id,
259
+ req: { tool: req.tool, kind: req.kind, title: req.title, detail: safe(req.detail, 4000), outside: !!req.outside },
260
+ });
261
+ }
262
+
263
+ private bufferDelta(kind: "text" | "reasoning", text: string): void {
264
+ if (this.bufKind && this.bufKind !== kind) this.flushDeltas();
265
+ this.bufKind = kind;
266
+ this.buf += text;
267
+ if (!this.flushTimer) {
268
+ this.flushTimer = setTimeout(() => {
269
+ this.flushTimer = undefined;
270
+ this.flushDeltas();
271
+ }, TEXT_FLUSH_MS);
272
+ }
273
+ }
274
+
275
+ private flushDeltas(): void {
276
+ if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
277
+ if (this.bufKind && this.buf) {
278
+ this.rawSend({ type: "event", event: { type: this.bufKind, text: this.buf } });
279
+ }
280
+ this.bufKind = null;
281
+ this.buf = "";
282
+ }
283
+ }
package/src/session.ts ADDED
@@ -0,0 +1,264 @@
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 { TodoStore } from "./tools/todoStore.ts";
13
+ import type { CheckpointStore } from "./memory/checkpoints.ts";
14
+ import type { ProcessRegistry } from "./tools/processRegistry.ts";
15
+ import { AttachmentStore } from "./util/attachmentStore.ts";
16
+ import { HookRunner, loadHooks, wrapToolsWithHooks } from "./hooks/engine.ts";
17
+ import { createLimiter } from "./util/limit.ts";
18
+
19
+ export interface SessionOptions {
20
+ config: Config;
21
+ modelSpec: string;
22
+ cwd: string;
23
+ gate?: PermissionGate;
24
+ // Confine file tools to cwd (default true). False lets the agent read/edit anywhere.
25
+ confineToCwd?: boolean;
26
+ // Out-of-cwd directories approved this session; shared with the gate so an approved
27
+ // location stops re-prompting. Pass the same array instance the gate holds.
28
+ allowedOutsideRoots?: string[];
29
+ // Active output style name (persona); resolved against .privateer/output-styles.
30
+ outputStyle?: string;
31
+ // When true, the system prompt instructs the model to plan, not implement.
32
+ planMode?: boolean;
33
+ // Session checkpoint store; write/edit record mutations into it for /rewind.
34
+ checkpoints?: CheckpointStore;
35
+ // Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
36
+ extraTools?: ToolSet;
37
+ // Background-shell registry for bash run_in_background / bash_output / kill_shell.
38
+ processes?: ProcessRegistry;
39
+ // Session attachment store, so dragged/pasted file bytes can be saved via the
40
+ // save_attachment tool. Created here when the caller doesn't supply one.
41
+ attachments?: AttachmentStore;
42
+ // Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
43
+ // tool-call id, so the TUI can render the grouped agents view. Best-effort.
44
+ onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
45
+ }
46
+
47
+ export interface Session {
48
+ engine: QueryEngine;
49
+ modelSpec: string;
50
+ provider: string;
51
+ modelId: string;
52
+ cwd: string;
53
+ todos: TodoStore;
54
+ attachments: AttachmentStore;
55
+ }
56
+
57
+ // Assemble a ready-to-run agent session: resolve the model, bind tools to the
58
+ // cwd + permission gate, build the system prompt, and create the engine.
59
+ export function createSession(opts: SessionOptions): Session {
60
+ const resolved = resolveModel(opts.modelSpec, opts.config);
61
+ const gate = opts.gate ?? autoApproveGate;
62
+ const confineToCwd = opts.confineToCwd ?? true;
63
+ const allowedOutsideRoots = opts.allowedOutsideRoots ?? [];
64
+ const todos = new TodoStore();
65
+ const attachments = opts.attachments ?? new AttachmentStore();
66
+ const cache = isAnthropicFamily(resolved.provider, resolved.modelId);
67
+
68
+ // Bound how many sub-agents run at once when the model fans `task` calls out.
69
+ const subAgentLimit = createLimiter(opts.config.maxSubagents);
70
+
71
+ // A `task` sub-agent: a fresh engine run to completion, returning the text it
72
+ // produced. Without an agent definition it uses the read-only toolset under an
73
+ // auto-approve gate; with one it uses that agent's tools (routed through the parent
74
+ // gate, so any mutations are still user-approved), model override, and instructions.
75
+ const runSubAgent: SubAgentRunner = ({ description, prompt, agent }) =>
76
+ subAgentLimit(async () => {
77
+ let model = resolved.model;
78
+ let childCache = cache;
79
+ if (agent?.model) {
80
+ try {
81
+ const r = resolveModel(agent.model, opts.config);
82
+ model = r.model;
83
+ childCache = isAnthropicFamily(r.provider, r.modelId);
84
+ } catch {
85
+ /* fall back to the parent model */
86
+ }
87
+ }
88
+ const system = agent
89
+ ? buildAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description, instructions: agent.prompt })
90
+ : buildSubAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description });
91
+ const tools = agent
92
+ ? createToolSubset({ cwd: opts.cwd, gate, confineToCwd, allowedOutsideRoots }, agent.tools)
93
+ : createReadOnlyTools({ cwd: opts.cwd, gate: autoApproveGate, confineToCwd, allowedOutsideRoots });
94
+
95
+ const child = new QueryEngine({
96
+ routes: singleRouteSet(agent?.model ?? opts.modelSpec, model, childCache),
97
+ system,
98
+ tools,
99
+ maxSteps: Math.min(opts.config.maxSteps, 20),
100
+ });
101
+ let out = "";
102
+ let toolUses = 0;
103
+ for await (const ev of child.send(prompt)) {
104
+ if (ev.type === "text") out += ev.text;
105
+ else if (ev.type === "tool-call") toolUses++;
106
+ else if (ev.type === "error")
107
+ return { text: `Sub-agent error: ${ev.error}`, toolUses, tokens: child.usage.totalTokens };
108
+ }
109
+ return {
110
+ text: out.trim() || "(sub-agent returned no output)",
111
+ toolUses,
112
+ tokens: child.usage.totalTokens,
113
+ };
114
+ });
115
+
116
+ const hooks = new HookRunner(loadHooks((opts.config as Record<string, unknown>).hooks), opts.cwd);
117
+ const tools = wrapToolsWithHooks(
118
+ {
119
+ ...createTools({
120
+ cwd: opts.cwd,
121
+ gate,
122
+ confineToCwd,
123
+ allowedOutsideRoots,
124
+ todos,
125
+ runSubAgent,
126
+ onSubAgentMetrics: opts.onSubAgentMetrics,
127
+ recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
128
+ processes: opts.processes,
129
+ attachments,
130
+ }),
131
+ ...(opts.extraTools ?? {}),
132
+ },
133
+ hooks,
134
+ );
135
+ const outputStyleBody = opts.outputStyle
136
+ ? findOutputStyle(opts.outputStyle, opts.cwd)?.body
137
+ : undefined;
138
+ const system = buildSystemPrompt({
139
+ cwd: opts.cwd,
140
+ model: opts.modelSpec,
141
+ outputStyleBody,
142
+ planMode: opts.planMode,
143
+ });
144
+
145
+ const engine = new QueryEngine({
146
+ routes: buildRouteSet(opts.config, opts.modelSpec, resolved.model, cache),
147
+ system,
148
+ tools,
149
+ maxSteps: opts.config.maxSteps,
150
+ contextBudget: opts.config.contextBudget,
151
+ compactRatio: opts.config.compactRatio,
152
+ });
153
+
154
+ return {
155
+ engine,
156
+ modelSpec: opts.modelSpec,
157
+ provider: resolved.provider,
158
+ modelId: resolved.modelId,
159
+ cwd: opts.cwd,
160
+ todos,
161
+ attachments,
162
+ };
163
+ }
164
+
165
+ // Anthropic prompt caching only benefits Anthropic-family models: direct Anthropic,
166
+ // or an OpenRouter route to an Anthropic model. For everything else the cache hints
167
+ // are a harmless no-op, but we skip them to avoid sending unused providerOptions.
168
+ function isAnthropicFamily(provider: string, modelId: string): boolean {
169
+ if (provider === "anthropic") return true;
170
+ if (provider === "openrouter") return modelId.startsWith("anthropic/");
171
+ return false;
172
+ }
173
+
174
+ // Short display name for UI notices: drop any "vendor/" prefix from the model id.
175
+ function shortLabel(spec: string): string {
176
+ const modelId = spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
177
+ return modelId.slice(modelId.lastIndexOf("/") + 1);
178
+ }
179
+
180
+ // Resolve a "provider:model" spec into a Route, deriving its per-model cache /
181
+ // thinking flags and supported input modalities from the model family.
182
+ function buildRoute(spec: string, config: Config): Route {
183
+ const r = resolveModel(spec, config);
184
+ const cache = isAnthropicFamily(r.provider, r.modelId);
185
+ return {
186
+ spec,
187
+ model: r.model,
188
+ cacheControl: cache,
189
+ thinkingBudget: cache ? config.thinkingBudget : undefined,
190
+ label: shortLabel(spec),
191
+ supports: modalitiesFor(r.provider, r.modelId),
192
+ };
193
+ }
194
+
195
+ // A trivial RouteSet with only the default route (sub-agents, which run one fixed
196
+ // model). The high `longThreshold` / zero `fastMaxChars` keep the router on default.
197
+ function singleRouteSet(spec: string, model: LanguageModel, cacheControl: boolean): RouteSet {
198
+ const { provider, modelId } = parseModelSpec(spec);
199
+ return {
200
+ default: { spec, model, cacheControl, label: shortLabel(spec), supports: modalitiesFor(provider, modelId) },
201
+ longThreshold: Number.POSITIVE_INFINITY,
202
+ fastMaxChars: 0,
203
+ };
204
+ }
205
+
206
+ // Pairs of (config key, RouteSet key, modality) for the modality routes.
207
+ const MODALITY_ROUTE_KEYS: { cfg: "vision" | "document" | "audio" | "video"; modality: Modality }[] = [
208
+ { cfg: "vision", modality: "image" },
209
+ { cfg: "document", modality: "document" },
210
+ { cfg: "audio", modality: "audio" },
211
+ { cfg: "video", modality: "video" },
212
+ ];
213
+
214
+ // Assemble the session's RouteSet: the default route (the already-resolved session
215
+ // model) plus any configured modality/long/fast routes, each tagged with the input
216
+ // modalities its model accepts. Optional routes that fail to resolve are skipped
217
+ // rather than failing the session. For each modality whose route is unset, hybrid
218
+ // auto-detect picks a capable model when the default can't handle that modality.
219
+ function buildRouteSet(
220
+ config: Config,
221
+ defaultSpec: string,
222
+ defaultModel: LanguageModel,
223
+ defaultCache: boolean,
224
+ ): RouteSet {
225
+ const router = config.router;
226
+ const tryRoute = (spec?: string): Route | undefined => {
227
+ if (!spec) return undefined;
228
+ try {
229
+ return buildRoute(spec, config);
230
+ } catch {
231
+ return undefined; // unconfigured/invalid optional route → ignored
232
+ }
233
+ };
234
+
235
+ const { provider: defProvider, modelId: defModelId } = parseModelSpec(defaultSpec);
236
+ const routes: RouteSet = {
237
+ default: {
238
+ spec: defaultSpec,
239
+ model: defaultModel,
240
+ cacheControl: defaultCache,
241
+ thinkingBudget: defaultCache ? config.thinkingBudget : undefined,
242
+ label: shortLabel(defaultSpec),
243
+ supports: modalitiesFor(defProvider, defModelId),
244
+ },
245
+ vision: tryRoute(router?.vision),
246
+ document: tryRoute(router?.document),
247
+ audio: tryRoute(router?.audio),
248
+ video: tryRoute(router?.video),
249
+ long: tryRoute(router?.long),
250
+ fast: tryRoute(router?.fast),
251
+ longThreshold: router?.longThreshold ?? Math.floor((config.contextBudget ?? 120_000) / 2),
252
+ fastMaxChars: router?.fastMaxChars ?? 280,
253
+ };
254
+
255
+ if (router?.auto ?? true) {
256
+ for (const { cfg, modality } of MODALITY_ROUTE_KEYS) {
257
+ if (routes[cfg]) continue; // explicitly configured → leave it
258
+ if (modelSupports(modality, defProvider, defModelId)) continue; // default handles it
259
+ const suggestion = suggestModelFor(modality, config);
260
+ if (suggestion) routes[cfg] = tryRoute(suggestion);
261
+ }
262
+ }
263
+ return routes;
264
+ }
@@ -0,0 +1,98 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import type { ToolContext } from "./context.ts";
4
+ import { exec } from "./exec.ts";
5
+ import { PermissionDeniedError } from "../permissions/gate.ts";
6
+
7
+ const DEFAULT_TIMEOUT = 120_000;
8
+ const MAX_TIMEOUT = 600_000;
9
+ // Cap how much command output enters the conversation. Unbounded output (a big
10
+ // `git diff`, a verbose build log) is otherwise re-sent on every subsequent step of
11
+ // the agentic loop, ballooning token usage. Keep the head and tail — both ends carry
12
+ // the most signal (the command's start and its final status/errors).
13
+ const MAX_OUTPUT_CHARS = 30_000;
14
+ const HEAD_CHARS = 20_000;
15
+
16
+ function clampOutput(text: string): string {
17
+ if (text.length <= MAX_OUTPUT_CHARS) return text;
18
+ const head = text.slice(0, HEAD_CHARS);
19
+ const tail = text.slice(text.length - (MAX_OUTPUT_CHARS - HEAD_CHARS));
20
+ const omitted = text.length - MAX_OUTPUT_CHARS;
21
+ return `${head}\n… (${omitted} chars of output truncated) …\n${tail}`;
22
+ }
23
+
24
+ export function bashTool(ctx: ToolContext) {
25
+ return tool({
26
+ description:
27
+ "Run a shell command in the working directory and return its output. " +
28
+ "Use for builds, tests, git, and other CLI tasks. Avoid long-running/interactive commands.",
29
+ inputSchema: z.object({
30
+ command: z.string().describe("The shell command to run."),
31
+ timeout: z.number().int().positive().optional().describe("Timeout in ms (max 600000)."),
32
+ run_in_background: z
33
+ .boolean()
34
+ .optional()
35
+ .describe("Run detached and return immediately; poll with bash_output, stop with kill_shell."),
36
+ }),
37
+ execute: async ({ command, timeout, run_in_background }) => {
38
+ const decision = await ctx.gate.request({
39
+ tool: "bash",
40
+ kind: "bash",
41
+ title: run_in_background ? "Run command (background)" : "Run command",
42
+ detail: command,
43
+ });
44
+ if (decision === "deny") throw new PermissionDeniedError("bash");
45
+
46
+ if (run_in_background) {
47
+ if (!ctx.processes) return "Background execution is not available in this context.";
48
+ const id = ctx.processes.spawn(command, ctx.cwd);
49
+ return `Started in background as ${id}. Read output with bash_output(bash_id="${id}"); stop with kill_shell(bash_id="${id}").`;
50
+ }
51
+
52
+ const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
53
+ const { stdout, stderr, code, timedOut } = await exec(command, [], {
54
+ cwd: ctx.cwd,
55
+ timeoutMs,
56
+ shell: true,
57
+ });
58
+
59
+ const parts: string[] = [];
60
+ if (stdout.trim()) parts.push(clampOutput(stdout.trimEnd()));
61
+ if (stderr.trim()) parts.push(`[stderr]\n${clampOutput(stderr.trimEnd())}`);
62
+ if (timedOut) parts.push(`[timed out after ${timeoutMs}ms]`);
63
+ parts.push(`[exit code ${code ?? "null"}]`);
64
+ return parts.join("\n");
65
+ },
66
+ });
67
+ }
68
+
69
+ export function bashOutputTool(ctx: ToolContext) {
70
+ return tool({
71
+ description:
72
+ "Read new output from a background shell started with bash run_in_background. Returns only " +
73
+ "output produced since the previous read, plus the shell's status.",
74
+ inputSchema: z.object({
75
+ bash_id: z.string().describe("The background shell id (e.g. bash_1)."),
76
+ }),
77
+ execute: async ({ bash_id }) => {
78
+ if (!ctx.processes) return "Background processes are not available in this context.";
79
+ const r = ctx.processes.read(bash_id);
80
+ if (!r) return `No background shell "${bash_id}".`;
81
+ const head = `[${r.status}${r.status === "exited" ? `, exit ${r.code ?? "null"}` : ""}]`;
82
+ return r.output ? `${head}\n${r.output.trimEnd()}` : `${head} (no new output)`;
83
+ },
84
+ });
85
+ }
86
+
87
+ export function killShellTool(ctx: ToolContext) {
88
+ return tool({
89
+ description: "Stop a background shell started with bash run_in_background.",
90
+ inputSchema: z.object({
91
+ bash_id: z.string().describe("The background shell id to stop."),
92
+ }),
93
+ execute: async ({ bash_id }) => {
94
+ if (!ctx.processes) return "Background processes are not available in this context.";
95
+ return ctx.processes.kill(bash_id) ? `Stopped ${bash_id}.` : `No background shell "${bash_id}".`;
96
+ },
97
+ });
98
+ }