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,499 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { Config, PermissionMode } from "../config/schema.ts";
4
+ import { PERMISSION_MODES } from "../config/schema.ts";
5
+ import { configLayers } from "../config/load.ts";
6
+ import { expandCommand, type CustomCommand } from "./custom.ts";
7
+ import { loadOutputStyles } from "../context/outputStyles.ts";
8
+ import { loadAgents } from "../agents/loader.ts";
9
+ import { loadHooks } from "../hooks/engine.ts";
10
+ import { configuredProviders, parseModelSpec } from "../providers/resolve.ts";
11
+ import { currentUser, serverBaseUrl } from "../auth/privateer.ts";
12
+ import { effectiveTokens } from "../engine/events.ts";
13
+ import { KNOWN_PROVIDERS } from "../config/schema.ts";
14
+ import type { UsageTotals } from "../engine/events.ts";
15
+ import type { TodoItem } from "../tools/todoStore.ts";
16
+ import { VERSION } from "../version.ts";
17
+
18
+ // The result of running a slash command. The App interprets these so command logic
19
+ // stays free of React/Ink concerns and is unit-testable.
20
+ export type CommandResult =
21
+ | { type: "notice"; text: string; tone?: "info" | "error" }
22
+ | { type: "clear" }
23
+ | { type: "exit" }
24
+ | { type: "setModel"; spec: string }
25
+ // Open the interactive model picker (fetches each provider's models live).
26
+ | { type: "pickModel" }
27
+ | { type: "setMode"; mode: PermissionMode }
28
+ // Hand a prompt to the agent to run as if the user had asked it (e.g. /init).
29
+ | { type: "runPrompt"; text: string }
30
+ // Summarize older history to free up context.
31
+ | { type: "compact" }
32
+ // Toggle modal (vim) editing in the prompt input.
33
+ | { type: "toggleVim" }
34
+ // Toggle OpenRouter Zero-Data-Retention enforcement (pins requests to ZDR endpoints).
35
+ | { type: "toggleZdr" }
36
+ // Fetch + display the NEAR AI TEE attestation for the current model (resolved by the App).
37
+ | { type: "verify" }
38
+ // Toggle full vs truncated tool output in the transcript.
39
+ | { type: "toggleVerbose" }
40
+ // Switch the active output style (persona); null resets to default.
41
+ | { type: "setOutputStyle"; name: string | null }
42
+ // Write the conversation transcript to a markdown file (path optional).
43
+ | { type: "export"; path?: string }
44
+ // Open the checkpoint picker to rewind conversation and/or files.
45
+ | { type: "rewind" }
46
+ // Open the session picker to browse and resume a past session.
47
+ | { type: "sessions" }
48
+ // Show live MCP server/tool status (resolved by the App).
49
+ | { type: "mcp" }
50
+ // Clear saved OAuth credentials for one MCP server, or all when omitted.
51
+ | { type: "mcpLogout"; server?: string }
52
+ // Re-enter the provider/key onboarding flow.
53
+ | { type: "onboarding" }
54
+ // Sign in to a Privateer account (device flow) for billed inference.
55
+ | { type: "privateerLogin" }
56
+ // Sign out of the Privateer account on this terminal.
57
+ | { type: "privateerLogout" }
58
+ // Toggle remote access (let the Privateer app drive this terminal). on=true/false; null=show status.
59
+ | { type: "remoteAccess"; on: boolean | null };
60
+
61
+ export interface CommandContext {
62
+ config: Config;
63
+ modelSpec: string;
64
+ mode: PermissionMode;
65
+ usage: UsageTotals;
66
+ // Current context-window occupancy (estimated tokens in live history vs budget).
67
+ // Distinct from `usage`, which is cumulative billed tokens across the whole session.
68
+ context?: { used: number; budget: number };
69
+ cwd: string;
70
+ todos: TodoItem[];
71
+ customCommands?: CustomCommand[];
72
+ }
73
+
74
+ interface CommandDef {
75
+ name: string;
76
+ summary: string;
77
+ run: (args: string, ctx: CommandContext) => CommandResult;
78
+ }
79
+
80
+ const COMMANDS: CommandDef[] = [
81
+ {
82
+ name: "help",
83
+ summary: "show available commands",
84
+ run: (_args, ctx) => {
85
+ const builtin = COMMANDS.map((c) => ` /${c.name.padEnd(11)} ${c.summary}`).join("\n");
86
+ const custom = (ctx.customCommands ?? [])
87
+ .map((c) => ` /${c.name.padEnd(11)} ${c.description}`)
88
+ .join("\n");
89
+ return {
90
+ type: "notice",
91
+ text: "Commands:\n" + builtin + (custom ? "\n\nCustom commands:\n" + custom : ""),
92
+ };
93
+ },
94
+ },
95
+ {
96
+ name: "model",
97
+ summary: "pick a model from your providers, or set one (provider:model)",
98
+ run: (args) => {
99
+ const spec = args.trim();
100
+ // No argument: open the interactive picker that lists each provider's live models.
101
+ if (!spec) return { type: "pickModel" };
102
+ try {
103
+ parseModelSpec(spec); // validate shape
104
+ } catch (err) {
105
+ return { type: "notice", tone: "error", text: (err as Error).message };
106
+ }
107
+ return { type: "setModel", spec };
108
+ },
109
+ },
110
+ {
111
+ name: "provider",
112
+ summary: "list providers and their readiness",
113
+ run: (_args, ctx) => {
114
+ const lines = configuredProviders(ctx.config).map(
115
+ (p) => ` ${p.ready ? "✓" : "·"} ${p.name}${p.ready ? "" : " (no key)"}`,
116
+ );
117
+ return {
118
+ type: "notice",
119
+ text: `Providers (${KNOWN_PROVIDERS.length}):\n${lines.join("\n")}`,
120
+ };
121
+ },
122
+ },
123
+ {
124
+ name: "login",
125
+ summary: "sign in to your Privateer account (inference billed to your subscription)",
126
+ run: () => ({ type: "privateerLogin" }),
127
+ },
128
+ {
129
+ name: "logout",
130
+ summary: "sign out of your Privateer account on this terminal",
131
+ run: () => ({ type: "privateerLogout" }),
132
+ },
133
+ {
134
+ name: "keys",
135
+ summary: "add or change provider API keys (bring your own)",
136
+ run: () => ({ type: "onboarding" }),
137
+ },
138
+ {
139
+ name: "remote-access",
140
+ summary: "let the Privateer app drive this terminal (on | off | status)",
141
+ run: (args) => {
142
+ const a = args.trim().toLowerCase();
143
+ if (a === "on" || a === "start" || a === "enable") return { type: "remoteAccess", on: true };
144
+ if (a === "off" || a === "stop" || a === "disable") return { type: "remoteAccess", on: false };
145
+ return { type: "remoteAccess", on: null };
146
+ },
147
+ },
148
+ {
149
+ name: "permissions",
150
+ summary: "show or set permission mode",
151
+ run: (args, ctx) => {
152
+ const m = args.trim();
153
+ if (!m) {
154
+ return {
155
+ type: "notice",
156
+ text: `Permission mode: ${ctx.mode}\nModes: ${PERMISSION_MODES.join(", ")}`,
157
+ };
158
+ }
159
+ if (!(PERMISSION_MODES as readonly string[]).includes(m)) {
160
+ return { type: "notice", tone: "error", text: `Unknown mode "${m}". Use: ${PERMISSION_MODES.join(", ")}` };
161
+ }
162
+ return { type: "setMode", mode: m as PermissionMode };
163
+ },
164
+ },
165
+ {
166
+ name: "cost",
167
+ summary: "show token usage this session",
168
+ run: (_args, ctx) => {
169
+ // Headline the billed (cache-discounted) figure: the agentic loop re-sends the
170
+ // prompt every step, so raw `totalTokens` counts each cached re-send at full
171
+ // weight and badly overstates cost. Anthropic bills cache reads at ~10%; show
172
+ // that estimate first, with the raw counts after for reference. See effectiveTokens.
173
+ const u = ctx.usage;
174
+ return {
175
+ type: "notice",
176
+ text:
177
+ `Tokens — billed ~${effectiveTokens(u)} (est, cache-discounted)\n` +
178
+ ` raw — in: ${u.inputTokens} out: ${u.outputTokens} total: ${u.totalTokens} cached: ${u.cachedInputTokens}`,
179
+ };
180
+ },
181
+ },
182
+ {
183
+ name: "init",
184
+ summary: "analyze the repo and write PRIVATEER.md (--stub for an empty template)",
185
+ run: (args, ctx) => {
186
+ const path = join(ctx.cwd, "PRIVATEER.md");
187
+ // Offline/empty template path.
188
+ if (args.trim() === "--stub") {
189
+ if (existsSync(path)) return { type: "notice", tone: "error", text: "PRIVATEER.md already exists." };
190
+ writeFileSync(
191
+ path,
192
+ "# Project context\n\nDescribe this project, conventions, and anything Privateer should\n" +
193
+ "always keep in mind here. This file is loaded into the system prompt.\n",
194
+ "utf8",
195
+ );
196
+ return { type: "notice", text: "Created PRIVATEER.md (stub)." };
197
+ }
198
+ // Default: let the agent investigate and write the file itself.
199
+ const verb = existsSync(path) ? "Update" : "Create";
200
+ return {
201
+ type: "runPrompt",
202
+ text:
203
+ `${verb} a PRIVATEER.md in the working directory. First explore the codebase ` +
204
+ `(read package manifests, build/test config, the directory layout, and a few key ` +
205
+ `source files). Then write a concise PRIVATEER.md covering: what the project is, how ` +
206
+ `to build/test/run it, the high-level architecture, and any conventions a contributor ` +
207
+ `should follow. Keep it tight — prefer bullet points over prose. Use the write tool.`,
208
+ };
209
+ },
210
+ },
211
+ {
212
+ name: "doctor",
213
+ summary: "environment diagnostics",
214
+ run: (_args, ctx) => {
215
+ const provs = configuredProviders(ctx.config)
216
+ .map((p) => `${p.name}:${p.ready ? "ready" : "—"}`)
217
+ .join(" ");
218
+ const layers = configLayers()
219
+ .map((l) => ` ${l.present ? "✓" : "·"} ${l.label} — ${l.path}`)
220
+ .join("\n");
221
+ const user = currentUser();
222
+ const account = user
223
+ ? `${user.email || user.solanaPublicKey || user.id} @ ${serverBaseUrl()}`
224
+ : "not signed in (use /login)";
225
+ return {
226
+ type: "notice",
227
+ text:
228
+ `Privateer v${VERSION}\n` +
229
+ ` node: ${process.version}\n` +
230
+ ` cwd: ${ctx.cwd}\n` +
231
+ ` model: ${ctx.modelSpec}\n` +
232
+ ` mode: ${ctx.mode}\n` +
233
+ ` account: ${account}\n` +
234
+ ` providers: ${provs}\n` +
235
+ ` config layers (low→high):\n${layers}`,
236
+ };
237
+ },
238
+ },
239
+ {
240
+ name: "config",
241
+ summary: "show the resolved settings layers (low→high precedence)",
242
+ run: () => {
243
+ const lines = configLayers().map(
244
+ (l) => ` ${l.present ? "✓ loaded" : "· absent"} ${l.label}\n ${l.path}`,
245
+ );
246
+ return {
247
+ type: "notice",
248
+ text:
249
+ "Settings layers (each overrides the ones above it):\n" +
250
+ lines.join("\n") +
251
+ "\n\nEdit any settings.json above; project settings.local.json is git-ignored.",
252
+ };
253
+ },
254
+ },
255
+ {
256
+ name: "todo",
257
+ summary: "show the current task list",
258
+ run: (_args, ctx) => {
259
+ if (ctx.todos.length === 0) return { type: "notice", text: "No tasks yet." };
260
+ const mark = { completed: "✔", in_progress: "▸", pending: "○" } as const;
261
+ const lines = ctx.todos.map((t) => ` ${mark[t.status]} ${t.content}`);
262
+ return { type: "notice", text: `Tasks:\n${lines.join("\n")}` };
263
+ },
264
+ },
265
+ {
266
+ name: "context",
267
+ summary: "show context-window usage",
268
+ run: (_args, ctx) => {
269
+ const u = ctx.usage;
270
+ const budget = ctx.config.contextBudget;
271
+ // Window occupancy is the size of the live history, NOT cumulative billed
272
+ // tokens — the agentic loop re-sends the prompt each step, so `usage.totalTokens`
273
+ // is many times the window and would report a meaningless % here.
274
+ const used = ctx.context?.used ?? 0;
275
+ const pct = budget ? Math.round((used / budget) * 100) : 0;
276
+ const compactAt = Math.round(budget * ctx.config.compactRatio);
277
+ const billed = effectiveTokens(u);
278
+ return {
279
+ type: "notice",
280
+ text:
281
+ `Context window:\n` +
282
+ ` used: ${used} tokens (${pct}% of ${budget})\n` +
283
+ ` auto-compact at ~${compactAt} tokens (ratio ${ctx.config.compactRatio})\n` +
284
+ `Session billed (cumulative, all turns):\n` +
285
+ ` in: ${u.inputTokens} out: ${u.outputTokens} cached: ${u.cachedInputTokens}\n` +
286
+ ` total: ${u.totalTokens} billed (est, cache-discounted): ~${billed} tokens`,
287
+ };
288
+ },
289
+ },
290
+ {
291
+ name: "memory",
292
+ summary: "show the project memory file (PRIVATEER.md)",
293
+ run: (_args, ctx) => {
294
+ const path = join(ctx.cwd, "PRIVATEER.md");
295
+ if (!existsSync(path)) {
296
+ return {
297
+ type: "notice",
298
+ text: "No PRIVATEER.md yet. Use /init to create one, or `# <note>` to append a line.",
299
+ };
300
+ }
301
+ const body = readFileSync(path, "utf8").trim();
302
+ const shown = body.length > 1500 ? body.slice(0, 1500) + "\n… (truncated)" : body;
303
+ return { type: "notice", text: `PRIVATEER.md:\n${shown}` };
304
+ },
305
+ },
306
+ {
307
+ name: "export",
308
+ summary: "write the conversation to a markdown file",
309
+ run: (args) => ({ type: "export", path: args.trim() || undefined }),
310
+ },
311
+ {
312
+ name: "rewind",
313
+ summary: "restore an earlier checkpoint (conversation and/or files)",
314
+ run: () => ({ type: "rewind" }),
315
+ },
316
+ {
317
+ name: "resume",
318
+ summary: "browse and resume a past session",
319
+ run: () => ({ type: "sessions" }),
320
+ },
321
+ {
322
+ name: "sessions",
323
+ summary: "browse and resume a past session",
324
+ run: () => ({ type: "sessions" }),
325
+ },
326
+ {
327
+ name: "mcp",
328
+ summary: "MCP servers: show status, or `logout [server]` to clear saved OAuth",
329
+ run: (args) => {
330
+ const [sub, ...rest] = args.trim().split(/\s+/).filter(Boolean);
331
+ if (sub === "logout") return { type: "mcpLogout", server: rest.join(" ") || undefined };
332
+ return { type: "mcp" };
333
+ },
334
+ },
335
+ {
336
+ name: "hooks",
337
+ summary: "list configured lifecycle hooks",
338
+ run: (_args, ctx) => {
339
+ const hooks = loadHooks((ctx.config as Record<string, unknown>).hooks);
340
+ const events = Object.keys(hooks);
341
+ if (events.length === 0) {
342
+ return {
343
+ type: "notice",
344
+ text: "No hooks configured. Add a `hooks` section to .privateer/settings.json (events: PreToolUse, PostToolUse, UserPromptSubmit, Stop).",
345
+ };
346
+ }
347
+ const lines = events.flatMap((ev) =>
348
+ (hooks[ev as keyof typeof hooks] ?? []).map(
349
+ (h) => ` ${ev}${h.matcher ? ` [${h.matcher}]` : ""}: ${h.command}`,
350
+ ),
351
+ );
352
+ return { type: "notice", text: `Hooks:\n${lines.join("\n")}` };
353
+ },
354
+ },
355
+ {
356
+ name: "agents",
357
+ summary: "list custom sub-agents from .privateer/agents",
358
+ run: (_args, ctx) => {
359
+ const agents = loadAgents(ctx.cwd);
360
+ if (agents.length === 0) {
361
+ return {
362
+ type: "notice",
363
+ text: "No custom sub-agents. Add markdown files under .privateer/agents/ (use them via the task tool's subagent_type).",
364
+ };
365
+ }
366
+ const lines = agents.map((a) => {
367
+ const tools = a.tools?.length ? a.tools.join(", ") : "read, glob, grep";
368
+ const model = a.model ? ` · ${a.model}` : "";
369
+ return ` ${a.name} (${a.scope})${model}\n ${a.description}\n tools: ${tools}`;
370
+ });
371
+ return { type: "notice", text: `Sub-agents:\n${lines.join("\n")}` };
372
+ },
373
+ },
374
+ {
375
+ name: "compact",
376
+ summary: "summarize older history to free up context",
377
+ run: () => ({ type: "compact" }),
378
+ },
379
+ {
380
+ name: "vim",
381
+ summary: "toggle modal (vim) editing in the prompt",
382
+ run: () => ({ type: "toggleVim" }),
383
+ },
384
+ {
385
+ name: "zdr",
386
+ summary: "toggle OpenRouter Zero-Data-Retention enforcement on requests",
387
+ run: (_args, ctx) => {
388
+ if (!ctx.config.providers.openrouter?.apiKey) {
389
+ return {
390
+ type: "notice",
391
+ tone: "error",
392
+ text: "ZDR enforcement applies to OpenRouter. Add an OpenRouter key with /login first.",
393
+ };
394
+ }
395
+ return { type: "toggleZdr" };
396
+ },
397
+ },
398
+ {
399
+ name: "verify",
400
+ summary: "show the NEAR AI TEE attestation proving the current model runs privately",
401
+ run: (_args, ctx) => {
402
+ let provider = "";
403
+ let modelId = "";
404
+ try {
405
+ ({ provider, modelId } = parseModelSpec(ctx.modelSpec));
406
+ } catch {
407
+ /* malformed model spec → handled below */
408
+ }
409
+ // NEAR models reachable two ways: BYO key (`nearai:*`) or account-billed
410
+ // through the Privateer server (`privateer:near/*`). Both are TEE-attestable.
411
+ const isNearai = provider === "nearai";
412
+ const isPrivateerNear = provider === "privateer" && modelId.startsWith("near/");
413
+ if (!isNearai && !isPrivateerNear) {
414
+ return {
415
+ type: "notice",
416
+ tone: "error",
417
+ text: "/verify attests NEAR AI TEE models. Switch to a nearai:* or privateer:near/* model with /model first.",
418
+ };
419
+ }
420
+ // BYO path needs a local key; the account path uses your Privateer session
421
+ // (the server holds the NEAR key), so the server call surfaces any auth error.
422
+ if (isNearai && !ctx.config.providers.nearai?.apiKey) {
423
+ return {
424
+ type: "notice",
425
+ tone: "error",
426
+ text: "No NEAR AI key. Add one with /login (or set NEAR_AI_API_KEY) to fetch an attestation.",
427
+ };
428
+ }
429
+ return { type: "verify" };
430
+ },
431
+ },
432
+ {
433
+ name: "verbose",
434
+ summary: "toggle full vs truncated tool output",
435
+ run: () => ({ type: "toggleVerbose" }),
436
+ },
437
+ {
438
+ name: "output-style",
439
+ summary: "switch the output style (persona), or list the available styles",
440
+ run: (args, ctx) => {
441
+ const name = args.trim();
442
+ const styles = loadOutputStyles(ctx.cwd);
443
+ if (!name) {
444
+ const current = ctx.config.outputStyle ?? "default";
445
+ const list = styles.length
446
+ ? styles.map((s) => ` ${s.name}${s.description ? " — " + s.description : ""}`).join("\n")
447
+ : " (none found in .privateer/output-styles)";
448
+ return {
449
+ type: "notice",
450
+ text:
451
+ `Output style: ${current}\nAvailable:\n${list}\n\n` +
452
+ `Use /output-style <name>, or /output-style default to reset.`,
453
+ };
454
+ }
455
+ if (name === "default") return { type: "setOutputStyle", name: null };
456
+ if (!styles.some((s) => s.name === name)) {
457
+ return { type: "notice", tone: "error", text: `No output style "${name}". See /output-style.` };
458
+ }
459
+ return { type: "setOutputStyle", name };
460
+ },
461
+ },
462
+ {
463
+ name: "clear",
464
+ summary: "clear the conversation",
465
+ run: () => ({ type: "clear" }),
466
+ },
467
+ {
468
+ name: "exit",
469
+ summary: "quit Privateer",
470
+ run: () => ({ type: "exit" }),
471
+ },
472
+ { name: "quit", summary: "quit Privateer", run: () => ({ type: "exit" }) },
473
+ ];
474
+
475
+ export const COMMAND_NAMES = COMMANDS.map((c) => c.name);
476
+
477
+ // Name + summary for each built-in command, for the slash-command autocomplete menu.
478
+ export const COMMAND_LIST: { name: string; summary: string }[] = COMMANDS.map((c) => ({
479
+ name: c.name,
480
+ summary: c.summary,
481
+ }));
482
+
483
+ // Built-ins plus any custom commands, for autocomplete.
484
+ export function commandList(custom: CustomCommand[] = []): { name: string; summary: string }[] {
485
+ return [...COMMAND_LIST, ...custom.map((c) => ({ name: c.name, summary: c.description }))];
486
+ }
487
+
488
+ // Parse and run a "/command args" line. Returns null if not a slash command.
489
+ // Falls through to custom commands (expanded into a prompt) before erroring.
490
+ export function runCommand(raw: string, ctx: CommandContext): CommandResult | null {
491
+ if (!raw.startsWith("/")) return null;
492
+ const [name, ...rest] = raw.slice(1).split(" ");
493
+ const args = rest.join(" ");
494
+ const cmd = COMMANDS.find((c) => c.name === name);
495
+ if (cmd) return cmd.run(args, ctx);
496
+ const custom = ctx.customCommands?.find((c) => c.name === name);
497
+ if (custom) return { type: "runPrompt", text: expandCommand(custom, args) };
498
+ return { type: "notice", tone: "error", text: `Unknown command "/${name}". Try /help.` };
499
+ }
@@ -0,0 +1,104 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import type { ToolEntry } from "./types.ts";
5
+ import { theme } from "./theme.ts";
6
+ import { formatTokens } from "./StatusBar.tsx";
7
+ import { BULLET, BRANCH, CORNER, VLINE } from "./figures.ts";
8
+
9
+ // Title-case a sub-agent type for the header ("code-reviewer" → "Code-reviewer");
10
+ // the default read-only explorer (no type) is shown as "Explore", matching the
11
+ // stock agent's role.
12
+ function agentLabel(subagentType?: string): string {
13
+ if (!subagentType) return "Explore";
14
+ return subagentType.charAt(0).toUpperCase() + subagentType.slice(1);
15
+ }
16
+
17
+ function statusWord(s: ToolEntry["status"]): string {
18
+ return s === "running" ? "Running…" : s === "error" ? "Failed" : "Done";
19
+ }
20
+
21
+ // The grouped "N agents finished" block for a run of concurrent `task` sub-agents,
22
+ // modeled on Claude Code's fan-out view: a header summarizing the batch, then one
23
+ // tree row per agent with its description, tool-use + token counts, and status.
24
+ // Collapsed (the default) shows just status; Ctrl+O expands each agent's full output.
25
+ export function AgentGroupView({
26
+ agents,
27
+ collapsed,
28
+ }: {
29
+ agents: ToolEntry[];
30
+ collapsed?: boolean;
31
+ }) {
32
+ const running = agents.some((a) => a.status === "running");
33
+ const errored = agents.some((a) => a.status === "error");
34
+ // A single shared label when every agent is the same type, else a bare "agents".
35
+ const types = new Set(agents.map((a) => a.agent?.subagentType ?? ""));
36
+ const label = types.size === 1 ? `${agentLabel([...types][0] || undefined)} ` : "";
37
+ const verb = running ? "running" : errored ? "finished (with errors)" : "finished";
38
+ const headColor = running ? theme.accent : errored ? theme.error : theme.success;
39
+
40
+ return (
41
+ <Box flexDirection="column" marginTop={1}>
42
+ <Box gap={1}>
43
+ {running ? (
44
+ <Text color={theme.accent}>
45
+ <Spinner type="dots" />
46
+ </Text>
47
+ ) : (
48
+ <Text color={headColor}>{BULLET}</Text>
49
+ )}
50
+ <Text>
51
+ <Text bold>
52
+ {agents.length} {label}agent{agents.length === 1 ? "" : "s"} {verb}
53
+ </Text>
54
+ {collapsed && !running && <Text color={theme.dim}> (ctrl+o to expand)</Text>}
55
+ </Text>
56
+ </Box>
57
+
58
+ <Box flexDirection="column" marginLeft={2}>
59
+ {agents.map((a, i) => {
60
+ const last = i === agents.length - 1;
61
+ const m = a.agent;
62
+ const metrics =
63
+ a.status !== "running" && m && (m.toolUses != null || m.tokens != null)
64
+ ? ` · ${m.toolUses ?? 0} tool uses · ${formatTokens(m.tokens ?? 0)} tokens`
65
+ : "";
66
+ const trunk = last ? " " : VLINE;
67
+ const body = a.status === "error" ? a.error ?? "" : a.output ?? "";
68
+ const outLines = body.trim() === "" ? [] : body.replace(/\n+$/, "").split("\n");
69
+ return (
70
+ <Box key={a.id} flexDirection="column">
71
+ <Text>
72
+ <Text color={theme.dim}>{last ? CORNER : BRANCH} </Text>
73
+ <Text bold>{m?.description ?? "agent"}</Text>
74
+ <Text color={theme.dim}>{metrics}</Text>
75
+ </Text>
76
+ <Text>
77
+ <Text color={theme.dim}>
78
+ {trunk} {CORNER}{" "}
79
+ </Text>
80
+ {a.status === "running" ? (
81
+ <Text color={theme.accent}>
82
+ <Spinner type="dots" /> {statusWord(a.status)}
83
+ </Text>
84
+ ) : (
85
+ <Text color={a.status === "error" ? theme.error : theme.dim} dimColor={a.status !== "error"}>
86
+ {statusWord(a.status)}
87
+ </Text>
88
+ )}
89
+ </Text>
90
+ {!collapsed &&
91
+ outLines.map((l, j) => (
92
+ <Text key={j} color={theme.dim} dimColor>
93
+ {trunk}
94
+ {" "}
95
+ {l}
96
+ </Text>
97
+ ))}
98
+ </Box>
99
+ );
100
+ })}
101
+ </Box>
102
+ </Box>
103
+ );
104
+ }