privateer-agent 0.1.1 → 0.3.0

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