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,1376 @@
1
+ import React, { useState, useRef, useEffect, useMemo } from "react";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Box, Text, Static, useApp, useInput, useStdout } from "ink";
5
+ import Spinner from "ink-spinner";
6
+ import { Banner } from "./Banner.tsx";
7
+ import { StatusBar, formatTokens, formatDuration } from "./StatusBar.tsx";
8
+ import { RowView, groupRows, visualRows, clampStreamingText } from "./Transcript.tsx";
9
+ import { ApprovalPrompt } from "./ApprovalPrompt.tsx";
10
+ import { ModelPicker } from "./ModelPicker.tsx";
11
+ import { PromptInput } from "./PromptInput.tsx";
12
+ import { PlanConfirm } from "./PlanConfirm.tsx";
13
+ import { ModeHint } from "./ModeHint.tsx";
14
+ import { useZdrShield } from "./useZdrShield.ts";
15
+ import { useTeeShield } from "./useTeeShield.ts";
16
+ import { parseModelSpec } from "../providers/resolve.ts";
17
+ import { fetchAttestation, fetchAttestationViaServer, teePosture } from "../providers/attestation.ts";
18
+ import { RewindPicker } from "./RewindPicker.tsx";
19
+ import { SessionPicker } from "./SessionPicker.tsx";
20
+ import { CheckpointStore, type RewindScope } from "../memory/checkpoints.ts";
21
+ import { ProcessRegistry } from "../tools/processRegistry.ts";
22
+ import { HookRunner, loadHooks } from "../hooks/engine.ts";
23
+ import type { ToolSet } from "ai";
24
+ import { loadMcpServers, connectMcpServers, type McpConnection } from "../mcp/client.ts";
25
+ import { hasStoredAuth, clearStoredAuth } from "../mcp/oauth.ts";
26
+ import { TodoPanel } from "./TodoPanel.tsx";
27
+ import { exec } from "../tools/exec.ts";
28
+ import { resolveAttachments, chipFor } from "../util/images.ts";
29
+ import type { Attachment } from "../util/images.ts";
30
+ import { AttachmentStore } from "../util/attachmentStore.ts";
31
+ import type { Entry, ToolEntry, Row } from "./types.ts";
32
+ import type { TodoStore, TodoItem } from "../tools/todoStore.ts";
33
+ import type { Config, PermissionMode } from "../config/schema.ts";
34
+ import { createSession } from "../session.ts";
35
+ import { QueryEngine } from "../engine/QueryEngine.ts";
36
+ import { emptyUsage, type UsageTotals } from "../engine/events.ts";
37
+ import { runCommand, commandList } from "../commands/registry.ts";
38
+ import { isSlashCommand } from "./promptModel.ts";
39
+ import { loadCustomCommands } from "../commands/custom.ts";
40
+ import { saveGlobalConfig } from "../config/load.ts";
41
+ import { logout as privateerLogout, hasCredentials } from "../auth/privateer.ts";
42
+ import { RelayClient } from "../remote/relayClient.ts";
43
+ import { ModeGate, type AskOutcome } from "../permissions/uiGate.ts";
44
+ import type { PermissionRequest } from "../permissions/gate.ts";
45
+ import {
46
+ saveSession,
47
+ loadSession,
48
+ listSessions,
49
+ newSessionId,
50
+ checkpointsDir,
51
+ type SessionData,
52
+ type SessionMeta,
53
+ } from "../memory/store.ts";
54
+ import { theme } from "./theme.ts";
55
+ import { DOWN } from "./figures.ts";
56
+ import { randomVerb } from "./spinnerVerbs.ts";
57
+
58
+ interface PendingApproval {
59
+ req: PermissionRequest;
60
+ resolve: (outcome: AskOutcome) => void;
61
+ }
62
+
63
+ const BANNER = "__banner__";
64
+
65
+ function asText(output: unknown): string {
66
+ return typeof output === "string" ? output : JSON.stringify(output);
67
+ }
68
+
69
+ // Rows of fixed chrome below the live transcript (spinner, todo, status bar, the
70
+ // bordered input + mode hint) that the streaming text must leave room for, so the
71
+ // dynamic region never outgrows the viewport and tips Ink into full-screen repaint.
72
+ const LIVE_CHROME_ROWS = 10;
73
+
74
+ // Cap the live tail's tall streaming blocks (assistant/thinking) to the viewport.
75
+ // Display-only: the underlying entries keep their full text for the final commit.
76
+ function clampLiveForViewport(tail: Entry[]): Entry[] {
77
+ const cols = Math.max(20, (process.stdout.columns || 80) - 2); // paddingX={1}
78
+ const maxRows = Math.max(6, (process.stdout.rows || 24) - LIVE_CHROME_ROWS);
79
+ return tail.map((e) =>
80
+ (e.kind === "assistant" || e.kind === "thinking") && visualRows(e.text, cols) > maxRows
81
+ ? { ...e, text: clampStreamingText(e.text, maxRows, cols) }
82
+ : e,
83
+ );
84
+ }
85
+
86
+ // Fold a finished sub-agent's run metrics into the entry's existing agent info
87
+ // (description/type set at call time), leaving non-task entries untouched.
88
+ function mergeAgentMetrics(
89
+ agent: ToolEntry["agent"],
90
+ m?: { toolUses: number; tokens: number },
91
+ ): ToolEntry["agent"] {
92
+ if (!agent) return agent;
93
+ return { ...agent, toolUses: m?.toolUses, tokens: m?.tokens };
94
+ }
95
+
96
+ // Project the committed transcript into structured feed items for a remote
97
+ // controller's catch-up snapshot, mirroring the live event kinds the app renders.
98
+ function snapshotEntries(entries: Entry[]): { kind: string; text: string }[] {
99
+ const out: { kind: string; text: string }[] = [];
100
+ for (const e of entries) {
101
+ if (e.kind === "user") out.push({ kind: "you", text: e.text });
102
+ else if (e.kind === "assistant") out.push({ kind: "assistant", text: e.text });
103
+ else if (e.kind === "thinking") out.push({ kind: "reasoning", text: e.text });
104
+ else if (e.kind === "tool") out.push({ kind: "tool", text: `▸ ${e.name} — ${e.status}` });
105
+ else if (e.kind === "notice") out.push({ kind: "notice", text: e.text });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Render the committed transcript as markdown for /export.
111
+ function serializeTranscript(entries: Entry[]): string {
112
+ const lines = [`# Privateer transcript`, `_${new Date().toISOString()}_`, ""];
113
+ for (const e of entries) {
114
+ if (e.kind === "user") lines.push(`## You`, "", e.text, "");
115
+ else if (e.kind === "assistant") lines.push(`## Privateer`, "", e.text, "");
116
+ else if (e.kind === "tool") lines.push(`- \`${e.name}\` — ${e.status}`, "");
117
+ else if (e.kind === "notice") lines.push(`> ${e.text}`, "");
118
+ }
119
+ return lines.join("\n");
120
+ }
121
+
122
+ export function App({
123
+ model,
124
+ config: initialConfig,
125
+ cwd,
126
+ resume,
127
+ onLogin,
128
+ onPrivateerLogin,
129
+ }: {
130
+ model: string;
131
+ config: Config;
132
+ cwd: string;
133
+ resume?: SessionData | null;
134
+ onLogin?: () => void;
135
+ onPrivateerLogin?: () => void;
136
+ }) {
137
+ // Config is state, not just a prop, so runtime toggles that change request
138
+ // behavior (e.g. /zdr) can update it and trigger a session rebuild.
139
+ const [config, setConfig] = useState<Config>(initialConfig);
140
+ const { exit } = useApp();
141
+ const { stdout } = useStdout();
142
+ // Bumped on resize to remount <Static> (forcing the whole transcript to be
143
+ // re-emitted) as part of a full repaint — see the resize effect below.
144
+ const [resizeNonce, setResizeNonce] = useState(0);
145
+ const [committed, setCommitted] = useState<Entry[]>([]);
146
+ const [live, setLive] = useState<Entry[]>([]);
147
+ const [busy, setBusy] = useState(false);
148
+ const [modelSpec, setModelSpec] = useState(model);
149
+ const zdr = useZdrShield(modelSpec, config);
150
+ const tee = useTeeShield(modelSpec, config);
151
+ // OpenRouter ZDR enforcement (set via /zdr); rebuilds the session when toggled so
152
+ // the provider preference (provider.zdr) rides on the next turn's requests.
153
+ const zdrEnforced = Boolean(config.providers.openrouter?.enforceZdr);
154
+ const [mode, setMode] = useState<PermissionMode>(config.permissionMode);
155
+ const [usage, setUsage] = useState<UsageTotals>(resume?.usage ?? emptyUsage());
156
+ // Context-window occupancy (Claude-Code-style "% of context") and per-turn cost,
157
+ // shown alongside the cumulative session usage so a one-word message visibly costs
158
+ // little. `turnUsage` ticks live during a turn; `lastTurnUsage` holds the last
159
+ // finished turn's total.
160
+ const [context, setContext] = useState<{ used: number; budget: number }>({ used: 0, budget: 0 });
161
+ const [turnUsage, setTurnUsage] = useState<UsageTotals>(emptyUsage());
162
+ const [lastTurnUsage, setLastTurnUsage] = useState<UsageTotals>(emptyUsage());
163
+ const [sessionError, setSessionError] = useState<string | null>(null);
164
+ const [pending, setPending] = useState<PendingApproval | null>(null);
165
+ const [picking, setPicking] = useState(false);
166
+ const [todos, setTodos] = useState<TodoItem[]>([]);
167
+ const [verb, setVerb] = useState(randomVerb());
168
+ const [elapsed, setElapsed] = useState(0);
169
+ const [queued, setQueued] = useState(0);
170
+ const [vim, setVim] = useState<boolean>(Boolean(config.vim));
171
+ const [outputStyle, setOutputStyle] = useState<string | null>(config.outputStyle ?? null);
172
+ const [planReady, setPlanReady] = useState(false);
173
+ const [rewinding, setRewinding] = useState(false);
174
+ const [sessionsPicking, setSessionsPicking] = useState(false);
175
+ const [sessions, setSessions] = useState<SessionMeta[]>([]);
176
+ const [mcpTools, setMcpTools] = useState<ToolSet>({});
177
+ const mcpRef = useRef<McpConnection | null>(null);
178
+ const [statusText, setStatusText] = useState("");
179
+ // Verbose expands tool output to its full text (truncated to a few lines
180
+ // otherwise). Driven both by `/verbose` and, in tandem with `collapsed`, by
181
+ // the Ctrl+O detail toggle below.
182
+ const [verbose, setVerbose] = useState(false);
183
+ // Collapsed view compacts the model's reasoning blocks to a single line each,
184
+ // so the transcript isn't dominated by thinking. Collapsed (and tool output
185
+ // truncated) is the default resting state; Ctrl+O flips both at once.
186
+ const [collapsed, setCollapsed] = useState(true);
187
+ const engineRef = useRef<QueryEngine | null>(null);
188
+ const todosRef = useRef<TodoStore | null>(null);
189
+ // Stable id for the session being written this run; reused when resuming so a
190
+ // continued session overwrites its own file instead of forking a new one.
191
+ const sessionIdRef = useRef(resume?.id ?? newSessionId());
192
+ const seededRef = useRef(false);
193
+ const abortRef = useRef<AbortController | null>(null);
194
+ // Input history (↑/↓) and the type-ahead queue for messages entered while busy.
195
+ // Queue items carry whether they were injected by a remote controller, so the
196
+ // remote flag is correct when the item eventually drains (not stale from a ref).
197
+ const historyRef = useRef<string[]>([]);
198
+ const queueRef = useRef<{ value: string; remote?: boolean }[]>([]);
199
+ const drainingRef = useRef(false);
200
+ // Monotonic counter for "[Image #n]" reference chips, shared across the session.
201
+ const imageSeqRef = useRef(0);
202
+ // Attachments the prompt input resolved live (on drag-drop/paste) and already
203
+ // rewrote to chips in the buffer text. Each turn claims the ones whose chip
204
+ // survives into its submitted text, so the base64 still rides along.
205
+ const pendingImagesRef = useRef<Attachment[]>([]);
206
+ // Session-lifetime checkpoint store (survives model/style switches) for /rewind,
207
+ // plus a live mirror of the committed transcript length for checkpointing. Bound to
208
+ // the session's on-disk checkpoint dir so /rewind survives a restart-and-resume; a
209
+ // fresh session loads an empty store from a dir that doesn't exist yet.
210
+ const checkpointsRef = useRef<CheckpointStore>(
211
+ CheckpointStore.load(checkpointsDir(cwd, sessionIdRef.current)),
212
+ );
213
+ const committedRef = useRef<Entry[]>([]);
214
+ // Background-shell registry, shared across the session for bash run_in_background.
215
+ const processesRef = useRef<ProcessRegistry>(new ProcessRegistry());
216
+ // Session-lifetime store of attachment bytes (by "#n"), so the save_attachment tool
217
+ // can write a pasted/dropped file to disk without re-reading the volatile drop path.
218
+ const attachmentsRef = useRef<AttachmentStore>(new AttachmentStore());
219
+ // Run metrics (tool uses + tokens) for `task` sub-agents, keyed by tool-call id and
220
+ // filled in when each agent finishes. Read when its tool-result arrives to annotate
221
+ // the grouped agents view. A plain ref (not state) — it's merged into the entry the
222
+ // result already re-renders.
223
+ const subAgentMetricsRef = useRef<Map<string, { toolUses: number; tokens: number }>>(new Map());
224
+
225
+ // ── Remote access (/remote-access): the Privateer app drives this terminal ───
226
+ const [remoteEnabled, setRemoteEnabled] = useState(false);
227
+ const relayRef = useRef<RelayClient | null>(null);
228
+ // True only while the active turn was injected by a remote controller. Read by
229
+ // the gate (policy) and by `ask` (route to the app, not the local prompt).
230
+ const currentTurnRemoteRef = useRef(false);
231
+ // Relayed tool approvals awaiting the app's Allow/Deny, keyed by request id.
232
+ // The original request is kept so a re-attaching controller can be re-sent any
233
+ // approvals it missed while detached (e.g. the app navigated away).
234
+ const pendingApprovalsRef = useRef<Map<string, { req: PermissionRequest; resolve: (o: AskOutcome) => void }>>(new Map());
235
+ // Always points at the latest handleInput so the relay effect (captured once)
236
+ // never dispatches with a stale `busy`.
237
+ const handleInputRef = useRef<(value: string, opts?: { remote?: boolean }) => void>(() => {});
238
+
239
+ // The gate reads the live mode via a ref (so changing mode doesn't require
240
+ // rebuilding the session/tools) and surfaces approvals through React state.
241
+ const modeRef = useRef(mode);
242
+ useEffect(() => {
243
+ modeRef.current = mode;
244
+ }, [mode]);
245
+ const allowlistRef = useRef<string[]>([...config.allowlist]);
246
+ // Out-of-cwd directories the user approves this session ("always" on an outside
247
+ // prompt). Shared between the gate (which appends) and the tools (which read), so an
248
+ // approved sibling location stops re-prompting.
249
+ const allowedOutsideRootsRef = useRef<string[]>([]);
250
+
251
+ // Custom slash commands from .privateer/commands, plus the merged autocomplete list.
252
+ const customCommands = useMemo(() => loadCustomCommands(cwd), [cwd]);
253
+ const commands = useMemo(() => commandList(customCommands), [customCommands]);
254
+ // Lifecycle hooks (UserPromptSubmit / Stop) configured in settings.
255
+ const hooks = useMemo(() => new HookRunner(loadHooks((config as any).hooks), cwd), [cwd]);
256
+
257
+ // Relay a tool-approval request to the app and await its Allow/Deny. Parks the
258
+ // resolver by id; resolves on the matching response, on a 120s timeout (→deny),
259
+ // or when the relay drops (the lifecycle effect drains pending → deny). Only
260
+ // refs are touched, so the gate's once-captured closure stays correct.
261
+ function relayAsk(req: PermissionRequest): Promise<AskOutcome> {
262
+ const client = relayRef.current;
263
+ if (!client) return Promise.resolve("deny"); // no controller to ask → fail safe
264
+ const id = `ap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
265
+ return new Promise<AskOutcome>((resolve) => {
266
+ const timeout = setTimeout(() => {
267
+ if (pendingApprovalsRef.current.delete(id)) {
268
+ append({ kind: "notice", tone: "error", text: "Remote approval timed out — denied." });
269
+ resolve("deny");
270
+ }
271
+ }, 120_000);
272
+ pendingApprovalsRef.current.set(id, {
273
+ req,
274
+ resolve: (o) => {
275
+ clearTimeout(timeout);
276
+ resolve(o);
277
+ },
278
+ });
279
+ client.requestApproval(id, req);
280
+ });
281
+ }
282
+
283
+ const gate = useMemo(
284
+ () =>
285
+ new ModeGate({
286
+ getMode: () => modeRef.current,
287
+ setMode: (m) => setMode(m),
288
+ allowlist: allowlistRef.current,
289
+ allowedOutsideRoots: allowedOutsideRootsRef.current,
290
+ denylist: config.denylist,
291
+ // Remote turns relay approvals to the app; local turns prompt in-terminal.
292
+ ask: (req) =>
293
+ currentTurnRemoteRef.current
294
+ ? relayAsk(req)
295
+ : new Promise<AskOutcome>((resolve) => setPending({ req, resolve })),
296
+ getRemote: () => currentTurnRemoteRef.current,
297
+ }),
298
+ [],
299
+ );
300
+
301
+ // Build (and rebuild on model / output-style change) the agent session, carrying
302
+ // history forward.
303
+ useEffect(() => {
304
+ try {
305
+ const prev = engineRef.current;
306
+ const prevTodos = todosRef.current?.get() ?? [];
307
+ const session = createSession({
308
+ config,
309
+ modelSpec,
310
+ cwd,
311
+ gate,
312
+ confineToCwd: config.confineToCwd,
313
+ allowedOutsideRoots: allowedOutsideRootsRef.current,
314
+ outputStyle: outputStyle ?? undefined,
315
+ planMode: mode === "plan",
316
+ checkpoints: checkpointsRef.current,
317
+ extraTools: mcpTools,
318
+ processes: processesRef.current,
319
+ attachments: attachmentsRef.current,
320
+ onSubAgentMetrics: (id, m) => subAgentMetricsRef.current.set(id, m),
321
+ });
322
+ if (prev) {
323
+ session.engine.messages.push(...prev.messages);
324
+ } else if (!seededRef.current && resume) {
325
+ // First build of a resumed session: restore prior history and usage.
326
+ session.engine.messages.push(...resume.messages);
327
+ session.engine.usage = resume.usage;
328
+ }
329
+ seededRef.current = true;
330
+ engineRef.current = session.engine;
331
+ // Carry the todo list across model switches and keep the panel in sync.
332
+ if (prevTodos.length) session.todos.set(prevTodos);
333
+ todosRef.current = session.todos;
334
+ setTodos(session.todos.get());
335
+ const unsub = session.todos.subscribe(setTodos);
336
+ setSessionError(null);
337
+ return unsub;
338
+ } catch (err) {
339
+ engineRef.current = null;
340
+ setSessionError(err instanceof Error ? err.message : String(err));
341
+ }
342
+ // Rebuild on model/style change, and when entering/leaving plan mode (so the
343
+ // system prompt gains or loses the plan-mode mandate) — not on every mode change.
344
+ // eslint-disable-next-line react-hooks/exhaustive-deps
345
+ }, [modelSpec, outputStyle, mode === "plan", mcpTools, zdrEnforced]);
346
+
347
+ // One-time notice when resuming a prior conversation.
348
+ useEffect(() => {
349
+ if (resume && resume.messages.length > 0) {
350
+ setCommitted((c) => [
351
+ { kind: "notice", text: `Resumed previous session (${resume.messages.length} messages).` },
352
+ ...c,
353
+ ]);
354
+ }
355
+ }, []);
356
+
357
+ // Keep a live mirror of the committed transcript so checkpoints can record its
358
+ // length synchronously (the useInput/runTurn closures can lag a render).
359
+ useEffect(() => {
360
+ committedRef.current = committed;
361
+ }, [committed]);
362
+
363
+ // Kill any background shells when the app unmounts.
364
+ useEffect(() => {
365
+ const procs = processesRef.current;
366
+ return () => procs.killAll();
367
+ }, []);
368
+
369
+ // Repaint cleanly when the terminal is resized.
370
+ //
371
+ // Ink commits the transcript once via <Static> and redraws only the footer
372
+ // below it, erasing the prior frame by its newline count. That count is
373
+ // width-unaware, so when the terminal reflows the previously-printed (always
374
+ // full-width) footer on a narrower drag, Ink under-erases and leaves a stale
375
+ // copy — one per resize event, which stacks into the duplicated status bars.
376
+ // There's no way to stop the terminal reflow, so on resize-settle we wipe the
377
+ // screen + scrollback and remount <Static> to re-emit the whole transcript at
378
+ // the new width. Debounced so it fires once when dragging stops, not per tick.
379
+ useEffect(() => {
380
+ if (!stdout) return;
381
+ let timer: ReturnType<typeof setTimeout> | undefined;
382
+ let lastCols = stdout.columns;
383
+ let lastRows = stdout.rows;
384
+ const onResize = () => {
385
+ clearTimeout(timer);
386
+ timer = setTimeout(() => {
387
+ // Terminals emit "resize" spuriously (focus changes, refreshes, and on
388
+ // some setups while a drag-selection scrolls the view) without the
389
+ // dimensions actually changing. The wipe below clears the screen *and*
390
+ // scrollback, so firing it on a non-resize destroys any active text
391
+ // selection out from under the user — which reads as the whole screen
392
+ // flashing while idle. Only repaint when the size genuinely changed.
393
+ if (stdout.columns === lastCols && stdout.rows === lastRows) return;
394
+ lastCols = stdout.columns;
395
+ lastRows = stdout.rows;
396
+ stdout.write("\x1b[2J\x1b[3J\x1b[H"); // clear screen + scrollback, home cursor
397
+ setResizeNonce((n) => n + 1); // remount <Static> → repaint transcript
398
+ }, 120);
399
+ };
400
+ stdout.on("resize", onResize);
401
+ return () => {
402
+ clearTimeout(timer);
403
+ stdout.off("resize", onResize);
404
+ };
405
+ }, [stdout]);
406
+
407
+ // Custom status line: run the configured command with session JSON on stdin and use
408
+ // its first line of stdout. Re-runs when the surfaced state changes. Best-effort.
409
+ useEffect(() => {
410
+ const cmd = config.statusLine;
411
+ if (!cmd) return;
412
+ let cancelled = false;
413
+ const payload = JSON.stringify({ model: modelSpec, mode, cwd, tokens: usage.totalTokens });
414
+ void exec(cmd, [], { cwd, timeoutMs: 5_000, shell: true, input: payload }).then((res) => {
415
+ if (!cancelled) setStatusText((res.stdout.split("\n")[0] ?? "").trim());
416
+ });
417
+ return () => {
418
+ cancelled = true;
419
+ };
420
+ // eslint-disable-next-line react-hooks/exhaustive-deps
421
+ }, [modelSpec, mode, usage.totalTokens]);
422
+
423
+ // Connect MCP servers from mcp.json once on mount; their tools merge into the
424
+ // session. Best-effort — failures are reported but never block the app.
425
+ useEffect(() => {
426
+ const servers = loadMcpServers(cwd);
427
+ if (Object.keys(servers).length === 0) return;
428
+ let cancelled = false;
429
+ const onAuthorize = ({ server, url }: { server: string; url: string }) => {
430
+ append({ kind: "notice", text: `MCP "${server}" needs authorization. Opening browser… if it doesn't open, visit:\n${url}` });
431
+ };
432
+ void connectMcpServers(servers, cwd, gate, onAuthorize).then((conn) => {
433
+ if (cancelled) {
434
+ conn.clients.forEach((c) => c.close());
435
+ return;
436
+ }
437
+ mcpRef.current = conn;
438
+ setMcpTools(conn.tools);
439
+ const ok = conn.status.filter((s) => !s.error);
440
+ const failed = conn.status.filter((s) => s.error);
441
+ if (ok.length) {
442
+ const n = ok.reduce((a, s) => a + s.tools, 0);
443
+ append({ kind: "notice", text: `MCP: connected ${ok.length} server(s), ${n} tool(s).` });
444
+ }
445
+ for (const s of failed) {
446
+ append({ kind: "notice", tone: "error", text: `MCP server "${s.server}" failed: ${s.error}` });
447
+ }
448
+ });
449
+ return () => {
450
+ cancelled = true;
451
+ mcpRef.current?.clients.forEach((c) => c.close());
452
+ };
453
+ // eslint-disable-next-line react-hooks/exhaustive-deps
454
+ }, [cwd]);
455
+
456
+ // Keep the relay's prompt entry point pointing at the freshest handleInput so a
457
+ // remote prompt queues/dispatches against the current `busy`, never a stale one.
458
+ handleInputRef.current = (value, opts) => handleInput(value, opts);
459
+
460
+ // Open/close the relay when /remote-access is toggled. The client is owned here
461
+ // (mirrors mcpRef) and torn down on disable/unmount. On teardown we resolve any
462
+ // parked approvals to "deny" so a dropped controller can't wedge a turn.
463
+ useEffect(() => {
464
+ if (!remoteEnabled) return;
465
+ const client = new RelayClient({
466
+ onPrompt: (text) => handleInputRef.current(text, { remote: true }),
467
+ onInterrupt: () => abortRef.current?.abort(),
468
+ onApprovalResponse: (id, decision) => {
469
+ const entry = pendingApprovalsRef.current.get(id);
470
+ if (entry) {
471
+ pendingApprovalsRef.current.delete(id);
472
+ entry.resolve(decision);
473
+ }
474
+ },
475
+ onControllerAttached: () => {
476
+ const client = relayRef.current;
477
+ if (!client) return;
478
+ client.sendSnapshot(snapshotEntries(committedRef.current));
479
+ // Re-surface approvals the controller missed while it was detached.
480
+ for (const [id, entry] of pendingApprovalsRef.current) client.requestApproval(id, entry.req);
481
+ },
482
+ onStatus: (text) => append({ kind: "notice", text }),
483
+ });
484
+ relayRef.current = client;
485
+ void client.start();
486
+ return () => {
487
+ client.stop();
488
+ relayRef.current = null;
489
+ for (const [id, entry] of pendingApprovalsRef.current) {
490
+ pendingApprovalsRef.current.delete(id);
491
+ entry.resolve("deny");
492
+ }
493
+ };
494
+ // eslint-disable-next-line react-hooks/exhaustive-deps
495
+ }, [remoteEnabled]);
496
+
497
+ function persist() {
498
+ const eng = engineRef.current;
499
+ if (!eng) return;
500
+ try {
501
+ saveSession(cwd, sessionIdRef.current, {
502
+ modelSpec,
503
+ messages: eng.messages,
504
+ usage: eng.usage,
505
+ });
506
+ } catch {
507
+ /* non-fatal */
508
+ }
509
+ }
510
+
511
+ // Shift+Tab cycles the permission mode in place (like Claude Code), without
512
+ // having to type /permissions. Dangerous bypass sits last so it takes three
513
+ // taps to reach from default.
514
+ const MODE_CYCLE: PermissionMode[] = ["default", "acceptEdits", "plan", "bypass"];
515
+ function cycleMode() {
516
+ const next = MODE_CYCLE[(MODE_CYCLE.indexOf(modeRef.current) + 1) % MODE_CYCLE.length];
517
+ setMode(next);
518
+ trySave({ ...config, permissionMode: next });
519
+ }
520
+
521
+ useInput((input, key) => {
522
+ if (key.ctrl && input === "c") exit();
523
+ // Esc interrupts an in-flight turn (the run loop persists partial output).
524
+ if (key.escape && busy && abortRef.current) abortRef.current.abort();
525
+ // Ctrl+O toggles detail level for the whole transcript: it expands/collapses
526
+ // both the model's reasoning blocks and full tool output together. (Reasoning
527
+ // only exists when extended thinking is enabled, so without also flipping tool
528
+ // output the key would appear to do nothing on a typical session.) The committed
529
+ // transcript lives in <Static>, so force a full repaint to re-render it.
530
+ if (key.ctrl && input === "o") {
531
+ const expanding = collapsed; // currently collapsed → this press expands
532
+ setCollapsed(!expanding);
533
+ setVerbose(expanding);
534
+ stdout?.write("\x1b[2J\x1b[3J\x1b[H");
535
+ setResizeNonce((n) => n + 1);
536
+ }
537
+ // Shift+Tab rotates the permission mode — but not while a modal overlay owns
538
+ // input (it has its own keybindings).
539
+ if (key.tab && key.shift && !pending && !picking && !rewinding && !planReady && !sessionsPicking)
540
+ cycleMode();
541
+ });
542
+
543
+ // Drive the elapsed-seconds counter shown beside the spinner while a turn runs.
544
+ useEffect(() => {
545
+ if (!busy) {
546
+ setElapsed(0);
547
+ return;
548
+ }
549
+ const started = Date.now();
550
+ const id = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000);
551
+ return () => clearInterval(id);
552
+ }, [busy]);
553
+
554
+ const append = (...entries: Entry[]) => setCommitted((c) => [...c, ...entries]);
555
+
556
+ function handleCommand(raw: string): boolean {
557
+ const res = runCommand(raw, { config, modelSpec, mode, usage, context, cwd, todos, customCommands });
558
+ if (!res) return false;
559
+ append({ kind: "user", text: raw });
560
+ switch (res.type) {
561
+ case "exit":
562
+ exit();
563
+ break;
564
+ case "clear":
565
+ setCommitted([]);
566
+ setLive([]);
567
+ setUsage(emptyUsage());
568
+ if (engineRef.current) {
569
+ engineRef.current.messages.length = 0;
570
+ engineRef.current.usage = emptyUsage();
571
+ }
572
+ todosRef.current?.set([]);
573
+ persist();
574
+ break;
575
+ case "setModel":
576
+ applyModel(res.spec);
577
+ break;
578
+ case "pickModel":
579
+ setPicking(true);
580
+ break;
581
+ case "setMode":
582
+ setMode(res.mode);
583
+ trySave({ ...config, permissionMode: res.mode });
584
+ append({ kind: "notice", text: `Permission mode: ${res.mode}` });
585
+ break;
586
+ case "runPrompt":
587
+ void runTurn(res.text, { hideInput: true });
588
+ break;
589
+ case "compact":
590
+ void doCompact();
591
+ break;
592
+ case "toggleVim": {
593
+ const next = !vim;
594
+ setVim(next);
595
+ trySave({ ...config, vim: next });
596
+ append({ kind: "notice", text: `Vim mode ${next ? "on" : "off"}.` });
597
+ break;
598
+ }
599
+ case "toggleZdr": {
600
+ const or = config.providers.openrouter ?? {};
601
+ const next = !or.enforceZdr;
602
+ // trySave updates config state too, so the zdrEnforced dep rebuilds the
603
+ // session and the provider.zdr preference rides on the next turn's requests.
604
+ trySave({
605
+ ...config,
606
+ providers: { ...config.providers, openrouter: { ...or, enforceZdr: next } },
607
+ });
608
+ append({
609
+ kind: "notice",
610
+ text: next
611
+ ? "ZDR enforcement on — OpenRouter requests pinned to zero-data-retention endpoints. Models without one will be rejected."
612
+ : "ZDR enforcement off — OpenRouter may route to endpoints that retain prompts.",
613
+ });
614
+ break;
615
+ }
616
+ case "verify": {
617
+ const { provider, modelId } = parseModelSpec(modelSpec);
618
+ append({ kind: "notice", text: `Fetching TEE attestation for ${modelId}…` });
619
+ // Account-billed NEAR models attest through the Privateer server proxy
620
+ // (NEAR key stays server-side); BYO nearai:* hits the gateway directly.
621
+ const attest =
622
+ provider === "privateer"
623
+ ? fetchAttestationViaServer(modelId)
624
+ : fetchAttestation(config.providers.nearai ?? {}, modelId);
625
+ void attest
626
+ .then((att) => {
627
+ const verdict =
628
+ teePosture(att) === "green"
629
+ ? "✓ Verified — confidential inference in a genuine TEE"
630
+ : teePosture(att) === "yellow"
631
+ ? "~ Attested, but couldn't fully confirm here (see verifier)"
632
+ : "✗ No attestation material returned";
633
+ const lines = [
634
+ `NEAR AI TEE attestation — ${modelId}`,
635
+ ` ${verdict}`,
636
+ ` Hardware: ${att.hardware.length ? att.hardware.join(" + ") : "none detected"}`,
637
+ ` Signing key: ${att.signingAddress ?? "not present"}`,
638
+ ` Nonce (fresh): ${att.nonceEchoed ? "yes" : "not echoed"} · ${att.nonce.slice(0, 16)}…`,
639
+ "",
640
+ "Your prompts are encrypted into the enclave (TLS terminates inside the TEE);",
641
+ "no infra/model provider — or NEAR — can read them. Full quote verification:",
642
+ "github.com/nearai/cloud-verifier",
643
+ ];
644
+ append({ kind: "notice", text: lines.join("\n") });
645
+ })
646
+ .catch((err) => {
647
+ append({ kind: "notice", tone: "error", text: `Attestation failed: ${String(err)}` });
648
+ });
649
+ break;
650
+ }
651
+ case "toggleVerbose": {
652
+ const next = !verbose;
653
+ setVerbose(next);
654
+ append({ kind: "notice", text: `Verbose tool output ${next ? "on" : "off"}.` });
655
+ break;
656
+ }
657
+ case "setOutputStyle":
658
+ setOutputStyle(res.name);
659
+ trySave({ ...config, outputStyle: res.name ?? undefined });
660
+ append({ kind: "notice", text: `Output style: ${res.name ?? "default"}.` });
661
+ break;
662
+ case "mcp": {
663
+ const servers = loadMcpServers(cwd);
664
+ const names = Object.keys(servers);
665
+ if (names.length === 0) {
666
+ append({
667
+ kind: "notice",
668
+ text: "No MCP servers. Add a `mcpServers` map to .privateer/mcp.json.",
669
+ });
670
+ break;
671
+ }
672
+ const conn = mcpRef.current;
673
+ const lines = names.map((name) => {
674
+ const cfg = servers[name] as { url?: string; headers?: unknown };
675
+ const st = conn?.status.find((s) => s.server === name);
676
+ const state = !st ? "· connecting…" : st.error ? `✗ ${st.error}` : `✓ ${st.tools} tool(s)`;
677
+ let auth = "";
678
+ if (typeof cfg.url === "string") {
679
+ auth = cfg.headers
680
+ ? " · static auth"
681
+ : hasStoredAuth(cfg.url)
682
+ ? " · oauth: authorized"
683
+ : " · oauth: not signed in";
684
+ }
685
+ return ` ${name} — ${state}${auth}`;
686
+ });
687
+ append({
688
+ kind: "notice",
689
+ text: `MCP servers:\n${lines.join("\n")}\n\n/mcp logout [server] clears saved OAuth.`,
690
+ });
691
+ break;
692
+ }
693
+ case "mcpLogout": {
694
+ const servers = loadMcpServers(cwd);
695
+ // Only remote servers that use OAuth (a URL, no static header) have stored creds.
696
+ const oauthServers = Object.entries(servers).filter(
697
+ ([, c]) => typeof (c as { url?: string }).url === "string" && !(c as { headers?: unknown }).headers,
698
+ );
699
+ const targets = res.server ? oauthServers.filter(([n]) => n === res.server) : oauthServers;
700
+ if (res.server && targets.length === 0) {
701
+ append({ kind: "notice", tone: "error", text: `No OAuth MCP server "${res.server}".` });
702
+ break;
703
+ }
704
+ for (const [, c] of targets) clearStoredAuth((c as { url: string }).url);
705
+ append({
706
+ kind: "notice",
707
+ text: targets.length
708
+ ? `Cleared saved OAuth for ${targets.length} server(s). Reconnect to re-authorize.`
709
+ : "No OAuth credentials to clear.",
710
+ });
711
+ break;
712
+ }
713
+ case "rewind":
714
+ if (checkpointsRef.current.list().length === 0) {
715
+ append({ kind: "notice", text: "No checkpoints yet — they're taken before each turn." });
716
+ } else {
717
+ setRewinding(true);
718
+ }
719
+ break;
720
+ case "sessions": {
721
+ // Exclude the in-progress session so the picker only offers prior ones.
722
+ const list = listSessions(cwd).filter((s) => s.id !== sessionIdRef.current);
723
+ if (list.length === 0) {
724
+ append({ kind: "notice", text: "No other saved sessions for this project yet." });
725
+ } else {
726
+ setSessions(list);
727
+ setSessionsPicking(true);
728
+ }
729
+ break;
730
+ }
731
+ case "export": {
732
+ const dest = res.path ?? join(cwd, `privateer-transcript-${Date.now()}.md`);
733
+ try {
734
+ writeFileSync(dest, serializeTranscript(committed), "utf8");
735
+ append({ kind: "notice", text: `Exported ${committed.length} entries to ${dest}` });
736
+ } catch (err) {
737
+ append({ kind: "notice", tone: "error", text: `Export failed: ${String(err)}` });
738
+ }
739
+ break;
740
+ }
741
+ case "onboarding":
742
+ onLogin?.();
743
+ break;
744
+ case "privateerLogin":
745
+ onPrivateerLogin?.();
746
+ break;
747
+ case "privateerLogout":
748
+ // logout() revokes this terminal's session server-side then clears local
749
+ // creds; fire-and-forget so the dispatch stays sync, report when done.
750
+ privateerLogout()
751
+ .then(() => append({ kind: "notice", text: "Signed out of your Privateer account on this terminal." }))
752
+ .catch((err) =>
753
+ append({ kind: "notice", tone: "error", text: `Sign-out problem: ${err instanceof Error ? err.message : String(err)}` }),
754
+ );
755
+ break;
756
+ case "remoteAccess": {
757
+ if (!hasCredentials()) {
758
+ append({ kind: "notice", tone: "error", text: "Sign in first with /login to enable remote access." });
759
+ break;
760
+ }
761
+ if (res.on === true) {
762
+ if (remoteEnabled) append({ kind: "notice", text: "Remote access is already on." });
763
+ else {
764
+ setRemoteEnabled(true);
765
+ append({
766
+ kind: "notice",
767
+ text: "Enabling remote access — open the Privateer app → Linked terminals → Drive. Tool actions will ask for your approval there.",
768
+ });
769
+ }
770
+ } else if (res.on === false) {
771
+ if (!remoteEnabled) append({ kind: "notice", text: "Remote access is already off." });
772
+ else {
773
+ setRemoteEnabled(false);
774
+ append({ kind: "notice", text: "Remote access disabled." });
775
+ }
776
+ } else {
777
+ append({ kind: "notice", text: remoteEnabled ? "Remote access is ON." : "Remote access is OFF. Use /remote-access on." });
778
+ }
779
+ break;
780
+ }
781
+ case "notice":
782
+ append({ kind: "notice", text: res.text, tone: res.tone });
783
+ break;
784
+ }
785
+ return true;
786
+ }
787
+
788
+ async function doCompact() {
789
+ const engine = engineRef.current;
790
+ if (!engine || busy) return;
791
+ setBusy(true);
792
+ try {
793
+ const res = await engine.compact();
794
+ append(
795
+ res
796
+ ? { kind: "notice", text: `Compacted context (~${res.before} → ~${res.after} tokens).` }
797
+ : { kind: "notice", text: "Nothing to compact yet." },
798
+ );
799
+ persist();
800
+ } catch (err) {
801
+ append({ kind: "notice", tone: "error", text: err instanceof Error ? err.message : String(err) });
802
+ } finally {
803
+ setBusy(false);
804
+ }
805
+ }
806
+
807
+ function applyModel(spec: string) {
808
+ setModelSpec(spec);
809
+ trySave({ ...config, defaultModel: spec });
810
+ append({ kind: "notice", text: `Model set to ${spec}` });
811
+ }
812
+
813
+ function trySave(next: Config) {
814
+ // Keep the in-memory config the single source of truth so independent toggles
815
+ // (mode, vim, model, zdr) compose instead of clobbering each other on disk.
816
+ setConfig(next);
817
+ try {
818
+ saveGlobalConfig(next);
819
+ } catch {
820
+ /* non-fatal: settings just won't persist */
821
+ }
822
+ }
823
+
824
+ // When the user starts a new turn after a task list has run to completion, fold the
825
+ // finished list into the static transcript as a one-time record and clear the live
826
+ // store. Otherwise TodoPanel keeps re-rendering the done plan above the status bar on
827
+ // every later prompt, long after the user has moved on to unrelated work.
828
+ function logCompletedTodos() {
829
+ const items = todosRef.current?.get() ?? [];
830
+ if (items.length === 0 || !items.every((t) => t.status === "completed")) return;
831
+ const lines = items.map((t) => ` ✔ ${t.content}`);
832
+ append({
833
+ kind: "notice",
834
+ text: [`Completed ${items.length} task${items.length === 1 ? "" : "s"}:`, ...lines].join("\n"),
835
+ });
836
+ todosRef.current?.set([]);
837
+ }
838
+
839
+ async function runTurn(text: string, opts?: { hideInput?: boolean; skipPlanConfirm?: boolean; remote?: boolean }) {
840
+ // A finished plan — every task completed — has served its purpose. Once the user
841
+ // prompts again they've moved on, so drop the all-done task panel here rather than
842
+ // letting the completed plan keep rendering above every subsequent turn. The todos
843
+ // live on in the transcript/message history; only the live panel is cleared.
844
+ const todoStore = todosRef.current;
845
+ const priorTodos = todoStore?.get() ?? [];
846
+ if (priorTodos.length > 0 && priorTodos.every((t) => t.status === "completed")) {
847
+ todoStore?.set([]);
848
+ }
849
+
850
+ // Claim any attachments the input already resolved live (drag-drop/paste): their
851
+ // chips are in `text`, so pull their base64 out of the pending list. Filtering by
852
+ // surviving chip drops ones the user edited away and is queue-safe (each turn
853
+ // takes only its own).
854
+ const liveAttachments = pendingImagesRef.current.filter((a) => text.includes(chipFor(a)));
855
+ pendingImagesRef.current = pendingImagesRef.current.filter((a) => !liveAttachments.includes(a));
856
+ // Rewrite any *still-raw* file paths (typed, or @-mentioned from the file menu) to
857
+ // short "[Kind #n]" chips, and inline referenced text/code files, before the prompt
858
+ // is checkpointed, shown, or sent. Binary attachments ride alongside the chip text
859
+ // into the model message; inlined text is appended to what the model receives.
860
+ const resolved = resolveAttachments(text, cwd, imageSeqRef.current, config.router?.inlineTextMaxBytes);
861
+ imageSeqRef.current += resolved.attachments.length;
862
+ text = resolved.text;
863
+ const attachments = [...liveAttachments, ...resolved.attachments];
864
+ // Persist each attachment's bytes to the session store so the save_attachment tool
865
+ // can write it to disk later, by its "#n", without touching the volatile drop path.
866
+ for (const a of attachments) attachmentsRef.current.register(a);
867
+ const inlinedText = resolved.inlinedText;
868
+
869
+ // Checkpoint the state before this turn so /rewind can return here.
870
+ const eng0 = engineRef.current;
871
+ if (eng0) {
872
+ checkpointsRef.current.create({
873
+ messagesLength: eng0.messages.length,
874
+ committedLength: committedRef.current.length,
875
+ label: text,
876
+ });
877
+ }
878
+ logCompletedTodos();
879
+ if (!opts?.hideInput) append({ kind: "user", text });
880
+ const engine = engineRef.current;
881
+ if (!engine) {
882
+ append({
883
+ kind: "notice",
884
+ tone: "error",
885
+ text: sessionError ?? "No model configured. Use /model or /provider.",
886
+ });
887
+ return;
888
+ }
889
+
890
+ // Mark whether this turn is remote-driven BEFORE any tool runs, so the gate
891
+ // routes approvals to the app and never auto-approves off bypass/allowlist.
892
+ currentTurnRemoteRef.current = !!opts?.remote;
893
+ setVerb(randomVerb());
894
+ setBusy(true);
895
+ setTurnUsage(emptyUsage());
896
+ const turnStart = Date.now();
897
+ const controller = new AbortController();
898
+ abortRef.current = controller;
899
+ let liveEntries: Entry[] = [];
900
+ let assistantIdx = -1;
901
+ let thinkingIdx = -1;
902
+ // How many leading `liveEntries` have already been promoted into the committed
903
+ // (<Static>) transcript. Everything from here on is what the repainting dynamic
904
+ // region actually shows.
905
+ let flushedThrough = 0;
906
+ // The dynamic region is redrawn whole on every spinner tick (~12×/s). If the
907
+ // turn's output is allowed to pile up there until the turn ends, a long turn —
908
+ // e.g. plan mode, which streams a big reasoning block plus a long plan with no
909
+ // tool calls to break it up — grows taller than the terminal, at which point
910
+ // Ink repaints the entire screen each frame: everything flickers and scrollback
911
+ // is clobbered. To avoid that we promote *settled* entries (anything no longer
912
+ // being streamed into) to <Static> as the turn runs, keeping the dynamic region
913
+ // short. The boundary is the first entry that may still change: the actively
914
+ // streaming assistant/thinking block, or a running tool. Concurrent `task` rows
915
+ // are held back too so groupRows can still merge the fan-out as one block.
916
+ const settledBoundary = (): number => {
917
+ let bound = liveEntries.length;
918
+ if (assistantIdx >= 0) bound = Math.min(bound, assistantIdx);
919
+ if (thinkingIdx >= 0) bound = Math.min(bound, thinkingIdx);
920
+ for (let i = flushedThrough; i < bound; i++) {
921
+ const e = liveEntries[i];
922
+ if (e.kind === "tool" && (e.status === "running" || e.name === "task")) return i;
923
+ }
924
+ return bound;
925
+ };
926
+ // Coalesce streaming re-renders. Pushing every token delta to state repaints
927
+ // the entire dynamic region per token, which thrashes the CPU and makes any
928
+ // in-progress text selection flicker. Throttle to a trailing flush (~30fps);
929
+ // the finally block clears the timer and does the final commit, so nothing is
930
+ // lost. Each flush also drains any newly-settled prefix into <Static> (batched
931
+ // with setLive, so no intermediate frame) and shows only the unsettled tail.
932
+ let syncTimer: ReturnType<typeof setTimeout> | undefined;
933
+ const sync = () => {
934
+ if (syncTimer) return;
935
+ syncTimer = setTimeout(() => {
936
+ syncTimer = undefined;
937
+ const bound = settledBoundary();
938
+ if (bound > flushedThrough) {
939
+ const promoted = liveEntries.slice(flushedThrough, bound);
940
+ flushedThrough = bound;
941
+ setCommitted((c) => [...c, ...promoted]);
942
+ }
943
+ setLive(clampLiveForViewport(liveEntries.slice(flushedThrough)));
944
+ }, 33);
945
+ };
946
+ const pushLive = (e: Entry) => {
947
+ liveEntries = [...liveEntries, e];
948
+ sync();
949
+ };
950
+
951
+ // The transcript shows `text` (chips + [file: …]); the model also receives the
952
+ // inlined contents of any read-as-text files.
953
+ let sendText = inlinedText ? `${text}\n\n${inlinedText}` : text;
954
+ try {
955
+ // UserPromptSubmit hooks may veto the turn or inject extra context.
956
+ if (hooks.has("UserPromptSubmit")) {
957
+ const outcome = await hooks.prompt(text);
958
+ if (outcome.block) {
959
+ pushLive({
960
+ kind: "notice",
961
+ tone: "error",
962
+ text: `Prompt blocked by hook${outcome.reason ? `: ${outcome.reason}` : ""}.`,
963
+ });
964
+ return;
965
+ }
966
+ if (outcome.additionalContext) {
967
+ sendText = `${sendText}\n\n[Hook context]\n${outcome.additionalContext}`;
968
+ }
969
+ }
970
+ for await (const ev of engine.send(sendText, controller.signal, attachments)) {
971
+ switch (ev.type) {
972
+ case "text":
973
+ thinkingIdx = -1;
974
+ if (assistantIdx === -1) {
975
+ pushLive({ kind: "assistant", text: ev.text });
976
+ assistantIdx = liveEntries.length - 1;
977
+ } else {
978
+ const idx = assistantIdx;
979
+ liveEntries = liveEntries.map((e, i) =>
980
+ i === idx && e.kind === "assistant" ? { ...e, text: e.text + ev.text } : e,
981
+ );
982
+ sync();
983
+ }
984
+ break;
985
+ case "reasoning":
986
+ if (thinkingIdx === -1) {
987
+ pushLive({ kind: "thinking", text: ev.text });
988
+ thinkingIdx = liveEntries.length - 1;
989
+ } else {
990
+ const idx = thinkingIdx;
991
+ liveEntries = liveEntries.map((e, i) =>
992
+ i === idx && e.kind === "thinking" ? { ...e, text: e.text + ev.text } : e,
993
+ );
994
+ sync();
995
+ }
996
+ break;
997
+ case "tool-call": {
998
+ // `task` calls carry the sub-agent's description/type so the grouped
999
+ // agents view can label each row before its metrics land.
1000
+ const o = (ev.input ?? {}) as Record<string, unknown>;
1001
+ const agent =
1002
+ ev.name === "task"
1003
+ ? {
1004
+ description: String(o.description ?? ""),
1005
+ subagentType: o.subagent_type ? String(o.subagent_type) : undefined,
1006
+ }
1007
+ : undefined;
1008
+ pushLive({ kind: "tool", id: ev.id, name: ev.name, input: ev.input, status: "running", agent });
1009
+ assistantIdx = -1;
1010
+ thinkingIdx = -1;
1011
+ break;
1012
+ }
1013
+ case "tool-result": {
1014
+ const m = subAgentMetricsRef.current.get(ev.id);
1015
+ liveEntries = liveEntries.map((e) =>
1016
+ e.kind === "tool" && e.id === ev.id
1017
+ ? { ...e, status: "done", output: asText(ev.output), agent: mergeAgentMetrics(e.agent, m) }
1018
+ : e,
1019
+ );
1020
+ sync();
1021
+ break;
1022
+ }
1023
+ case "tool-error": {
1024
+ const m = subAgentMetricsRef.current.get(ev.id);
1025
+ liveEntries = liveEntries.map((e) =>
1026
+ e.kind === "tool" && e.id === ev.id
1027
+ ? { ...e, status: "error", error: ev.error, agent: mergeAgentMetrics(e.agent, m) }
1028
+ : e,
1029
+ );
1030
+ sync();
1031
+ break;
1032
+ }
1033
+ case "usage":
1034
+ // Live running total — ticks the token count up between steps.
1035
+ setUsage(ev.usage);
1036
+ setTurnUsage(ev.turn);
1037
+ setContext(engine.contextUsage());
1038
+ break;
1039
+ case "finish":
1040
+ setUsage(engine.usage);
1041
+ // ev.usage is this turn's authoritative total (see QueryEngine finish).
1042
+ setLastTurnUsage(ev.usage);
1043
+ setContext(engine.contextUsage());
1044
+ break;
1045
+ case "aborted":
1046
+ pushLive({ kind: "notice", text: "Interrupted." });
1047
+ break;
1048
+ case "compacted":
1049
+ pushLive({ kind: "notice", text: `Auto-compacted context (~${ev.before} → ~${ev.after} tokens).` });
1050
+ break;
1051
+ case "routed":
1052
+ pushLive(
1053
+ ev.missing && ev.missing.length > 0
1054
+ ? {
1055
+ kind: "notice",
1056
+ tone: "error",
1057
+ text: `No model configured for ${ev.missing.join("/")} input — ${ev.label} may not process it. Set router.${ev.missing[0] === "image" ? "vision" : ev.missing[0]}.`,
1058
+ }
1059
+ : { kind: "notice", text: `↪ routed to ${ev.label}${ev.reason ? ` · ${ev.reason}` : ""}` },
1060
+ );
1061
+ break;
1062
+ case "error":
1063
+ pushLive({ kind: "notice", tone: "error", text: ev.error, hint: ev.hint });
1064
+ break;
1065
+ }
1066
+ // Mirror the live stream to any attached controller (no-op when remote is off).
1067
+ relayRef.current?.sendEvent(ev);
1068
+ }
1069
+ } catch (err) {
1070
+ const msg = err instanceof Error ? err.message : String(err);
1071
+ pushLive({ kind: "notice", tone: "error", text: msg });
1072
+ // A throw escapes the for-await before the per-event tee, so relay it
1073
+ // explicitly — otherwise a driven turn that fails (e.g. no inference key)
1074
+ // looks like silence on the controller.
1075
+ relayRef.current?.sendEvent({ type: "error", error: msg });
1076
+ } finally {
1077
+ abortRef.current = null;
1078
+ currentTurnRemoteRef.current = false;
1079
+ // Cancel any pending throttled flush so it can't re-emit these entries into
1080
+ // the live region after we've moved them into the committed transcript.
1081
+ clearTimeout(syncTimer);
1082
+ // Close out the turn with how long the agent took to process the request to
1083
+ // completion — the live spinner's running timer, frozen as a total.
1084
+ const took = Date.now() - turnStart;
1085
+ // Only the tail that streaming hasn't already promoted into <Static> remains.
1086
+ const finalEntries: Entry[] = [
1087
+ ...liveEntries.slice(flushedThrough),
1088
+ { kind: "notice", text: `⏱ ${formatDuration(took)} total` },
1089
+ ];
1090
+ setLive([]);
1091
+ setCommitted((c) => [...c, ...finalEntries]);
1092
+ setBusy(false);
1093
+ persist();
1094
+ if (hooks.has("Stop")) void hooks.stop();
1095
+ // In plan mode, once the agent has presented a plan, offer to leave plan mode.
1096
+ // Inspect the whole turn (some of it may have already been promoted to
1097
+ // <Static>), not just the tail still in finalEntries.
1098
+ if (
1099
+ !opts?.skipPlanConfirm &&
1100
+ modeRef.current === "plan" &&
1101
+ liveEntries.some((e) => e.kind === "assistant" && e.text.trim().length > 0)
1102
+ ) {
1103
+ setPlanReady(true);
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ function approvePlan() {
1109
+ setPlanReady(false);
1110
+ setMode("default");
1111
+ trySave({ ...config, permissionMode: "default" });
1112
+ append({ kind: "notice", text: "Plan approved — exited plan mode. Tell me to proceed." });
1113
+ }
1114
+
1115
+ // Dismiss the confirmation and return to the prompt while staying in plan mode, so
1116
+ // the user can ask questions about the plan without it reading as approval.
1117
+ function chatAboutPlan() {
1118
+ setPlanReady(false);
1119
+ append({ kind: "notice", text: "Still in plan mode — ask about the plan or refine it." });
1120
+ }
1121
+
1122
+ function restoreCheckpoint(id: string, scope: RewindScope) {
1123
+ setRewinding(false);
1124
+ const store = checkpointsRef.current;
1125
+ const cp = store.get(id);
1126
+ if (!cp) return;
1127
+ if (scope === "files" || scope === "both") store.restoreFiles(cp);
1128
+ if (scope === "conversation" || scope === "both") {
1129
+ const eng = engineRef.current;
1130
+ if (eng && eng.messages.length > cp.messagesLength) eng.messages.length = cp.messagesLength;
1131
+ setCommitted(committedRef.current.slice(0, cp.committedLength));
1132
+ setLive([]);
1133
+ }
1134
+ persist();
1135
+ append({ kind: "notice", text: `Rewound to "${cp.label}" (${scope}).` });
1136
+ }
1137
+
1138
+ // Swap the live conversation for a stored one. Like startup --continue, this reseeds
1139
+ // the engine's context (and adopts that session's id so further turns persist back to
1140
+ // it) rather than replaying the old transcript as visible history.
1141
+ function resumeSession(id: string) {
1142
+ setSessionsPicking(false);
1143
+ const data = loadSession(cwd, id);
1144
+ const eng = engineRef.current;
1145
+ if (!data || !eng) {
1146
+ append({ kind: "notice", tone: "error", text: "Could not load that session." });
1147
+ return;
1148
+ }
1149
+ eng.messages.length = 0;
1150
+ eng.messages.push(...data.messages);
1151
+ eng.usage = data.usage;
1152
+ setUsage(data.usage);
1153
+ setCommitted([]);
1154
+ setLive([]);
1155
+ todosRef.current?.set([]);
1156
+ sessionIdRef.current = data.id;
1157
+ // Adopt the resumed session's checkpoints so /rewind acts on its history, not the
1158
+ // one we just left. Mutated in place so the engine's recordMutation closure stays
1159
+ // valid (the session isn't rebuilt on resume).
1160
+ checkpointsRef.current.adopt(checkpointsDir(cwd, data.id));
1161
+ persist();
1162
+ append({ kind: "notice", text: `Resumed session (${data.messages.length} messages).` });
1163
+ }
1164
+
1165
+ // Entry point from the prompt input. While a turn is running, messages are
1166
+ // queued and drained in order when it finishes.
1167
+ function handleInput(value: string, opts?: { remote?: boolean }) {
1168
+ const text = value.trim();
1169
+ if (!text) return;
1170
+ if (busy || drainingRef.current) {
1171
+ queueRef.current.push({ value, remote: opts?.remote });
1172
+ setQueued(queueRef.current.length);
1173
+ append({ kind: "notice", text: `Queued (${queueRef.current.length}) — runs after the current turn.` });
1174
+ return;
1175
+ }
1176
+ void dispatchInput(value, opts?.remote);
1177
+ }
1178
+
1179
+ async function dispatchInput(value: string, remote?: boolean) {
1180
+ const text = value.trim();
1181
+ // Remote-driven input is ALWAYS a model turn: never interpret `!bash`, `#memory`
1182
+ // or slash commands from the app (a `!` shortcut would bypass the gate entirely).
1183
+ if (remote) {
1184
+ await runTurn(text, { remote: true });
1185
+ return;
1186
+ }
1187
+ if (isSlashCommand(text)) {
1188
+ handleCommand(text);
1189
+ return;
1190
+ }
1191
+ if (text.startsWith("!")) {
1192
+ await runBash(text.slice(1).trim());
1193
+ return;
1194
+ }
1195
+ if (text.startsWith("#")) {
1196
+ addMemory(text.slice(1).trim());
1197
+ return;
1198
+ }
1199
+ await runTurn(text);
1200
+ }
1201
+
1202
+ // Drain queued messages once the UI is idle. Runs them sequentially so turns
1203
+ // never overlap.
1204
+ async function drainQueue() {
1205
+ if (drainingRef.current || busy) return;
1206
+ drainingRef.current = true;
1207
+ try {
1208
+ while (queueRef.current.length > 0) {
1209
+ const next = queueRef.current.shift()!;
1210
+ setQueued(queueRef.current.length);
1211
+ await dispatchInput(next.value, next.remote);
1212
+ }
1213
+ } finally {
1214
+ drainingRef.current = false;
1215
+ }
1216
+ }
1217
+
1218
+ useEffect(() => {
1219
+ if (!busy) void drainQueue();
1220
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1221
+ }, [busy]);
1222
+
1223
+ // `!cmd` — run a shell command locally and show its output, without a model turn.
1224
+ async function runBash(cmd: string) {
1225
+ if (!cmd) return;
1226
+ append({ kind: "user", text: `!${cmd}` });
1227
+ setBusy(true);
1228
+ try {
1229
+ const res = await exec(cmd, [], { cwd, timeoutMs: 120_000, shell: true });
1230
+ const out = [res.stdout, res.stderr].filter(Boolean).join("\n").trim();
1231
+ append({
1232
+ kind: "tool",
1233
+ id: `bash-${Date.now()}`,
1234
+ name: "bash",
1235
+ input: { command: cmd },
1236
+ status: res.code === 0 ? "done" : "error",
1237
+ output: out || "(no output)",
1238
+ error: res.code === 0 ? undefined : res.timedOut ? "timed out" : `exit ${res.code}`,
1239
+ });
1240
+ } catch (err) {
1241
+ append({ kind: "notice", tone: "error", text: err instanceof Error ? err.message : String(err) });
1242
+ } finally {
1243
+ setBusy(false);
1244
+ }
1245
+ }
1246
+
1247
+ // `#note` — append a bullet to the project's PRIVATEER.md memory file.
1248
+ function addMemory(note: string) {
1249
+ if (!note) return;
1250
+ const path = join(cwd, "PRIVATEER.md");
1251
+ try {
1252
+ const head = existsSync(path)
1253
+ ? readFileSync(path, "utf8").replace(/\s*$/, "") + "\n"
1254
+ : "# Project context\n";
1255
+ writeFileSync(path, `${head}\n- ${note}\n`, "utf8");
1256
+ append({ kind: "notice", text: `Added to memory (PRIVATEER.md): ${note}` });
1257
+ } catch (err) {
1258
+ append({ kind: "notice", tone: "error", text: `Could not write PRIVATEER.md: ${String(err)}` });
1259
+ }
1260
+ }
1261
+
1262
+ // Group concurrent `task` sub-agents into single rows before committing to <Static>
1263
+ // (and again for the live region below) so the fan-out renders as one block.
1264
+ const staticItems: (typeof BANNER | Row)[] = [BANNER, ...groupRows(committed)];
1265
+
1266
+ return (
1267
+ <Box flexDirection="column">
1268
+ <Static key={resizeNonce} items={staticItems}>
1269
+ {(item, i) =>
1270
+ item === BANNER ? (
1271
+ <Box key="banner" paddingX={1} paddingTop={1}>
1272
+ <Banner model={modelSpec} />
1273
+ </Box>
1274
+ ) : (
1275
+ <Box key={i} paddingX={1}>
1276
+ <RowView row={item as Row} verbose={verbose} collapsed={collapsed} />
1277
+ </Box>
1278
+ )
1279
+ }
1280
+ </Static>
1281
+
1282
+ <Box flexDirection="column" paddingX={1}>
1283
+ {groupRows(live).map((row, i) => (
1284
+ <RowView key={i} row={row} verbose={verbose} collapsed={collapsed} />
1285
+ ))}
1286
+
1287
+ {/* While a prompt is pending the turn is blocked on the human, so there's
1288
+ no work to animate. Crucially, ink-spinner re-renders the whole dynamic
1289
+ region every frame; left running it would erase+redraw the bordered
1290
+ ApprovalPrompt below it ~10×/s, which reads as the box flickering. */}
1291
+ {busy && !pending && (
1292
+ <Box marginTop={1} gap={1}>
1293
+ <Text color={theme.accent}>
1294
+ <Spinner type="dots" />
1295
+ </Text>
1296
+ <Text color={theme.accent} wrap="truncate-end">
1297
+ {verb}…
1298
+ </Text>
1299
+ <Text color={theme.dim} wrap="truncate-end">
1300
+ (esc to interrupt · {elapsed}s · {DOWN} {formatTokens(turnUsage.outputTokens)} tokens)
1301
+ </Text>
1302
+ </Box>
1303
+ )}
1304
+
1305
+ <TodoPanel todos={todos} />
1306
+
1307
+ <StatusBar
1308
+ modelSpec={modelSpec}
1309
+ cwd={cwd}
1310
+ usage={usage}
1311
+ context={context}
1312
+ lastTurn={lastTurnUsage}
1313
+ custom={statusText || undefined}
1314
+ zdr={zdr}
1315
+ tee={tee}
1316
+ />
1317
+
1318
+ {picking ? (
1319
+ <ModelPicker
1320
+ config={config}
1321
+ onSelect={(spec) => {
1322
+ setPicking(false);
1323
+ applyModel(spec);
1324
+ }}
1325
+ onCancel={() => setPicking(false)}
1326
+ />
1327
+ ) : pending ? (
1328
+ <ApprovalPrompt
1329
+ req={pending.req}
1330
+ onRespond={(outcome) => {
1331
+ pending.resolve(outcome);
1332
+ setPending(null);
1333
+ }}
1334
+ />
1335
+ ) : rewinding ? (
1336
+ <RewindPicker
1337
+ checkpoints={checkpointsRef.current.list()}
1338
+ onRestore={restoreCheckpoint}
1339
+ onCancel={() => setRewinding(false)}
1340
+ />
1341
+ ) : sessionsPicking ? (
1342
+ <SessionPicker
1343
+ sessions={sessions}
1344
+ onResume={resumeSession}
1345
+ onCancel={() => setSessionsPicking(false)}
1346
+ />
1347
+ ) : planReady ? (
1348
+ <PlanConfirm
1349
+ onApprove={approvePlan}
1350
+ onChat={chatAboutPlan}
1351
+ onKeep={() => setPlanReady(false)}
1352
+ />
1353
+ ) : (
1354
+ <>
1355
+ <PromptInput
1356
+ busy={busy}
1357
+ cwd={cwd}
1358
+ queued={queued}
1359
+ vimEnabled={vim}
1360
+ commands={commands}
1361
+ history={historyRef}
1362
+ imageSeqRef={imageSeqRef}
1363
+ pendingImagesRef={pendingImagesRef}
1364
+ onSubmit={handleInput}
1365
+ onClear={() => {
1366
+ setCommitted([]);
1367
+ setLive([]);
1368
+ }}
1369
+ />
1370
+ <ModeHint mode={mode} collapsed={collapsed} />
1371
+ </>
1372
+ )}
1373
+ </Box>
1374
+ </Box>
1375
+ );
1376
+ }