privateer-agent 0.2.1 → 0.3.0

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