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