atom-agent 1.4.0 → 1.5.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/CHANGELOG.md +40 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +1 -1
- package/dist/ui/diff-view.js +13 -5
- package/dist/ui/diff.js +67 -0
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +7 -5
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +81 -22
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +8 -5
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
// WebUI session runtime: the headless bridge between the browser and the
|
|
2
|
+
// existing ATOM agentic loop. The WebUI is ONLY another frontend for the same
|
|
3
|
+
// runtime — this module reuses it instead of duplicating it:
|
|
4
|
+
//
|
|
5
|
+
// - Turns run through runAgenticLoopForProvider (the same shared
|
|
6
|
+
// runLoopWithChat core the TUI uses), with the same per-turn contract:
|
|
7
|
+
// history[0] carries withEnvBlock(buildSystemPrompt(cwd)), refreshed once
|
|
8
|
+
// per turn; failed/cancelled turns splice history back to the turn start
|
|
9
|
+
// (see src/rollback.ts) and never persist; completed turns persist via the
|
|
10
|
+
// sessions store (see src/sessions.ts).
|
|
11
|
+
// - Permissions mirror App.approve exactly: decidePolicy over
|
|
12
|
+
// {mode, trustAll:false, rules:[], alwaysAllowed, skillGrants:∅,
|
|
13
|
+
// approvalGated:needsApproval(name)} — deny refuses, plan-passthrough flows
|
|
14
|
+
// to the execute gate, prompt blocks on the browser's POSTed decision.
|
|
15
|
+
// - Plan mode reuses the exact replan-note string from App.guardedExecute.
|
|
16
|
+
// - Cancellation uses AbortController → LoopCancelledError, same as Ctrl+C.
|
|
17
|
+
// - ask_question resolves through the browser (question_request event +
|
|
18
|
+
// POSTed answer); without a waiting browser it degrades exactly as the
|
|
19
|
+
// loop's no-hook path does (never hangs, never throws).
|
|
20
|
+
//
|
|
21
|
+
// Deliberately OUT of v1 scope (TUI-only surfaces, not loop features):
|
|
22
|
+
// skill auto-invoke, compaction, telemetry traces, goal auto-continue hooks
|
|
23
|
+
// (no opts.goal → the loop runs its byte-identical no-goal path; update_goal
|
|
24
|
+
// resolves to the standard outside-turn error, exactly as in the TUI with no
|
|
25
|
+
// goal set), follow-up queue/steer (concurrent send is a 409), extension
|
|
26
|
+
// lifecycle commands. The goal/tool hooks stay available for later prompts.
|
|
27
|
+
import { LoopCancelledError } from "../agent/loop.js";
|
|
28
|
+
import { loadAuth, getStoredBaseURL, resolveApiKey } from "../auth.js";
|
|
29
|
+
import { withEnvBlock } from "../env-block.js";
|
|
30
|
+
import { decidePolicy } from "../policy.js";
|
|
31
|
+
import { getProvider, isProviderId, PROVIDERS, providerNeedsKey, } from "../providers.js";
|
|
32
|
+
import { cancelledTurnLine } from "../rollback.js";
|
|
33
|
+
import { createSession, getSession, listSessions, updateSession, } from "../sessions.js";
|
|
34
|
+
import { allToolDefinitions, describeToolCall, executeTool, needsApproval, previewDiffForApproval, previewLangFromPath, } from "../tools.js";
|
|
35
|
+
import { computeDiff, computeSideBySide, } from "../ui/diff.js";
|
|
36
|
+
import { readFileSync, statSync } from "node:fs";
|
|
37
|
+
import * as path from "node:path";
|
|
38
|
+
import { buildSystemPrompt, normalizeEffort, runAgenticLoopForProvider, } from "../zen.js";
|
|
39
|
+
import { createWebEvent, eventsAfter } from "./events.js";
|
|
40
|
+
// Exact replan refusal from App.guardedExecute (ticket 04): the WebUI must
|
|
41
|
+
// refuse plan-mode mutations with the same model-visible text, so behavior
|
|
42
|
+
// never drifts between frontends.
|
|
43
|
+
export function planModeRefusal(name) {
|
|
44
|
+
return (`Error: plan mode is read-only — ${name} blocked (no writes while planning). ` +
|
|
45
|
+
`Explore with read/grep/glob/web tools, record the plan with todowrite, then Tab out of plan mode to implement.`);
|
|
46
|
+
}
|
|
47
|
+
// Cap for result text inside tool_result events: SSE frames stay small and
|
|
48
|
+
// the DOM never renders megabytes per call. Capped payloads carry
|
|
49
|
+
// truncated:true plus the full length, so the UI can say so honestly.
|
|
50
|
+
export const EVENT_RESULT_CAP = 4000;
|
|
51
|
+
export function truncateEventText(text, cap = EVENT_RESULT_CAP) {
|
|
52
|
+
if (text.length <= cap)
|
|
53
|
+
return { text, truncated: false, chars: text.length };
|
|
54
|
+
return { text: text.slice(0, cap), truncated: true, chars: text.length };
|
|
55
|
+
}
|
|
56
|
+
// Cap for diff-side text in file_diff events (cut at a newline so no fake
|
|
57
|
+
// partial last line). 32KB keeps SSE frames and the DOM small; the full
|
|
58
|
+
// lengths ride along so the UI states the truncation honestly.
|
|
59
|
+
export const FILE_DIFF_CAP = 32768;
|
|
60
|
+
export const FILE_DIFF_ROWS_CAP = 400;
|
|
61
|
+
export function classifyWriteOp(oldText) {
|
|
62
|
+
return oldText === null ? "created" : "modified";
|
|
63
|
+
}
|
|
64
|
+
export function cutAtNewline(text, cap = FILE_DIFF_CAP) {
|
|
65
|
+
if (text.length <= cap)
|
|
66
|
+
return { text, truncated: false, chars: text.length };
|
|
67
|
+
const cut = text.lastIndexOf("\n", cap);
|
|
68
|
+
const end = cut > cap / 2 ? cut : cap;
|
|
69
|
+
return { text: text.slice(0, end), truncated: true, chars: text.length };
|
|
70
|
+
}
|
|
71
|
+
// Capped disk read for diff evidence (same fail-null contract as App's
|
|
72
|
+
// readFileForDiff, smaller cap — display evidence, not approval preview).
|
|
73
|
+
function readCappedForDiff(absPath) {
|
|
74
|
+
try {
|
|
75
|
+
const st = statSync(absPath);
|
|
76
|
+
if (!st.isFile() || st.size > FILE_DIFF_CAP)
|
|
77
|
+
return null;
|
|
78
|
+
return readFileSync(absPath, "utf8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// In-memory overlay per session: the persisted record (history/turns/
|
|
85
|
+
// settings in sessions.ts) plus live turn state. Bounded event log (cap
|
|
86
|
+
// below) doubles as the reconnect replay buffer.
|
|
87
|
+
const EVENT_LOG_CAP = 1000;
|
|
88
|
+
let approvalIdCounter = 0;
|
|
89
|
+
let questionIdCounter = 0;
|
|
90
|
+
function isRecord(value) {
|
|
91
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
92
|
+
}
|
|
93
|
+
function accumulateUsage(prev, u) {
|
|
94
|
+
const next = { ...(prev ?? {}) };
|
|
95
|
+
if (u.prompt_tokens !== undefined) {
|
|
96
|
+
next.prompt_tokens = (next.prompt_tokens ?? 0) + u.prompt_tokens;
|
|
97
|
+
}
|
|
98
|
+
if (u.completion_tokens !== undefined) {
|
|
99
|
+
next.completion_tokens = (next.completion_tokens ?? 0) + u.completion_tokens;
|
|
100
|
+
}
|
|
101
|
+
if (u.total_tokens !== undefined) {
|
|
102
|
+
next.total_tokens = (next.total_tokens ?? 0) + u.total_tokens;
|
|
103
|
+
}
|
|
104
|
+
if (u.cacheReadTokens !== undefined) {
|
|
105
|
+
next.cacheReadTokens = (next.cacheReadTokens ?? 0) + u.cacheReadTokens;
|
|
106
|
+
}
|
|
107
|
+
if (u.cacheWriteTokens !== undefined) {
|
|
108
|
+
next.cacheWriteTokens = (next.cacheWriteTokens ?? 0) + u.cacheWriteTokens;
|
|
109
|
+
}
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
export class WebRuntime {
|
|
113
|
+
home;
|
|
114
|
+
states = new Map();
|
|
115
|
+
constructor(home) {
|
|
116
|
+
this.home = home;
|
|
117
|
+
}
|
|
118
|
+
// ---- session records (persisted store, same as the TUI) ----
|
|
119
|
+
listSessions() {
|
|
120
|
+
return listSessions(this.home);
|
|
121
|
+
}
|
|
122
|
+
getSessionRecord(id) {
|
|
123
|
+
return getSession(id, this.home);
|
|
124
|
+
}
|
|
125
|
+
// Live view for the item route and SSE clients: persisted identity
|
|
126
|
+
// (title/dates) plus the in-memory turn state (history/turns/settings/
|
|
127
|
+
// busy/pending). A fresh client renders this, then streams events — it
|
|
128
|
+
// never misses the running turn's user echo or a cancelled-turn line that
|
|
129
|
+
// (by rollback contract) never reaches the store.
|
|
130
|
+
getLiveSession(id) {
|
|
131
|
+
const record = getSession(id, this.home);
|
|
132
|
+
if (!record)
|
|
133
|
+
return null;
|
|
134
|
+
const state = this.stateFor(record);
|
|
135
|
+
return {
|
|
136
|
+
...record,
|
|
137
|
+
provider: state.provider,
|
|
138
|
+
model: state.model,
|
|
139
|
+
effort: state.effort,
|
|
140
|
+
mode: state.mode,
|
|
141
|
+
usageTotals: state.usageTotals ? { ...state.usageTotals } : null,
|
|
142
|
+
history: state.history.map((m) => ({ ...m })),
|
|
143
|
+
turns: state.turns.map((t) => ({ ...t })),
|
|
144
|
+
busy: state.busy,
|
|
145
|
+
pendingApproval: this.getPendingApproval(id),
|
|
146
|
+
pendingQuestion: this.getPendingQuestion(id),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
createWebSession(opts) {
|
|
150
|
+
const provider = opts?.provider && isProviderId(opts.provider) ? opts.provider : "opencode-zen";
|
|
151
|
+
const cwd = process.cwd();
|
|
152
|
+
const history = [
|
|
153
|
+
{ role: "system", content: withEnvBlock(buildSystemPrompt(cwd)) },
|
|
154
|
+
];
|
|
155
|
+
const session = createSession({
|
|
156
|
+
title: opts?.title,
|
|
157
|
+
cwd,
|
|
158
|
+
provider,
|
|
159
|
+
model: opts?.model ?? getProvider(provider)?.defaultModel ?? "",
|
|
160
|
+
effort: normalizeEffort(opts?.effort ?? "auto"),
|
|
161
|
+
mode: opts?.mode ?? "normal",
|
|
162
|
+
history,
|
|
163
|
+
turns: [],
|
|
164
|
+
}, this.home);
|
|
165
|
+
this.stateFor(session);
|
|
166
|
+
return session;
|
|
167
|
+
}
|
|
168
|
+
updateWebSession(id, patch) {
|
|
169
|
+
const current = getSession(id, this.home);
|
|
170
|
+
if (!current)
|
|
171
|
+
return null;
|
|
172
|
+
const next = updateSession(id, {
|
|
173
|
+
...(patch.provider && isProviderId(patch.provider) ? { provider: patch.provider } : {}),
|
|
174
|
+
...(typeof patch.model === "string" ? { model: patch.model } : {}),
|
|
175
|
+
...(patch.effort !== undefined ? { effort: normalizeEffort(patch.effort) } : {}),
|
|
176
|
+
...(patch.mode === "normal" || patch.mode === "yolo" || patch.mode === "plan"
|
|
177
|
+
? { mode: patch.mode }
|
|
178
|
+
: {}),
|
|
179
|
+
...(typeof patch.title === "string" && patch.title.trim().length > 0
|
|
180
|
+
? { title: patch.title.trim() }
|
|
181
|
+
: {}),
|
|
182
|
+
}, this.home);
|
|
183
|
+
if (next) {
|
|
184
|
+
const state = this.states.get(id);
|
|
185
|
+
if (state && !state.busy)
|
|
186
|
+
this.syncStateFromRecord(state, next);
|
|
187
|
+
}
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
// ---- realtime fan-out ----
|
|
191
|
+
subscribe(id, listener, lastEventId) {
|
|
192
|
+
const record = getSession(id, this.home);
|
|
193
|
+
if (!record)
|
|
194
|
+
throw new Error(`session not found: ${id}`);
|
|
195
|
+
const state = this.stateFor(record);
|
|
196
|
+
// Reconnect replay: events after the client's last seen id, oldest first.
|
|
197
|
+
for (const evt of eventsAfter(state.eventLog, lastEventId)) {
|
|
198
|
+
try {
|
|
199
|
+
listener(evt);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// a throwing listener must not break subscribe
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
state.listeners.add(listener);
|
|
206
|
+
return () => {
|
|
207
|
+
state.listeners.delete(listener);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// ---- turns ----
|
|
211
|
+
isBusy(id) {
|
|
212
|
+
return this.states.get(id)?.busy ?? false;
|
|
213
|
+
}
|
|
214
|
+
getPendingApproval(id) {
|
|
215
|
+
const p = this.states.get(id)?.pendingApproval ?? null;
|
|
216
|
+
if (!p)
|
|
217
|
+
return null;
|
|
218
|
+
return { id: p.id, name: p.name, args: p.args, description: p.description, diff: p.diff };
|
|
219
|
+
}
|
|
220
|
+
getPendingQuestion(id) {
|
|
221
|
+
const p = this.states.get(id)?.pendingQuestion ?? null;
|
|
222
|
+
if (!p)
|
|
223
|
+
return null;
|
|
224
|
+
return { id: p.id, question: p.question, options: p.options, allowCustom: p.allowCustom };
|
|
225
|
+
}
|
|
226
|
+
resolveApproval(id, approvalId, decision) {
|
|
227
|
+
const state = this.states.get(id);
|
|
228
|
+
const pending = state?.pendingApproval ?? null;
|
|
229
|
+
if (!state || !pending || pending.id !== approvalId)
|
|
230
|
+
return false;
|
|
231
|
+
if (decision === "always")
|
|
232
|
+
state.alwaysAllowed.add(pending.name);
|
|
233
|
+
state.pendingApproval = null;
|
|
234
|
+
this.emit(state, "approval_resolved", { id: approvalId, decision });
|
|
235
|
+
pending.resolve(decision);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
answerQuestion(id, questionId, answer) {
|
|
239
|
+
const state = this.states.get(id);
|
|
240
|
+
const pending = state?.pendingQuestion ?? null;
|
|
241
|
+
if (!state || !pending || pending.id !== questionId)
|
|
242
|
+
return false;
|
|
243
|
+
if (typeof answer !== "string" || answer.length === 0)
|
|
244
|
+
return false;
|
|
245
|
+
state.pendingQuestion = null;
|
|
246
|
+
this.emit(state, "question_resolved", { id: questionId });
|
|
247
|
+
pending.resolve(answer);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
cancelTurn(id) {
|
|
251
|
+
const state = this.states.get(id);
|
|
252
|
+
if (!state || !state.busy || !state.controller)
|
|
253
|
+
return false;
|
|
254
|
+
state.controller.abort();
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
// Synchronous start-gate: validates the turn and applies any settings
|
|
258
|
+
// overrides, throwing on anything that prevents the turn from starting
|
|
259
|
+
// (unknown session, empty text, busy, missing key/model). The HTTP layer
|
|
260
|
+
// calls this inside try/catch BEFORE the fire-and-forget sendMessage —
|
|
261
|
+
// an async function never throws synchronously, so awaiting sendMessage
|
|
262
|
+
// would hold the request for the whole turn while `void` would swallow
|
|
263
|
+
// start-failures as 202s. Single-threaded and await-free, so the gate +
|
|
264
|
+
// send pair below is atomic (no interleaving turn can sneak in).
|
|
265
|
+
validateTurnStart(id, content, opts) {
|
|
266
|
+
const record = getSession(id, this.home);
|
|
267
|
+
if (!record)
|
|
268
|
+
throw new Error(`session not found: ${id}`);
|
|
269
|
+
const text = content.trim();
|
|
270
|
+
if (!text)
|
|
271
|
+
throw new Error("message must be a non-empty string");
|
|
272
|
+
const state = this.stateFor(record);
|
|
273
|
+
if (state.busy)
|
|
274
|
+
throw new Error("session is busy (another turn is running)");
|
|
275
|
+
if (state.pendingApproval || state.pendingQuestion) {
|
|
276
|
+
throw new Error("session is waiting on an approval or question");
|
|
277
|
+
}
|
|
278
|
+
// Per-message settings overrides apply to the session record first (same
|
|
279
|
+
// persistence as the TUI's /model + /provider + /effort picks).
|
|
280
|
+
if (opts?.provider || opts?.model !== undefined || opts?.effort !== undefined || opts?.mode) {
|
|
281
|
+
const updated = this.updateWebSession(id, {
|
|
282
|
+
...(opts.provider ? { provider: opts.provider } : {}),
|
|
283
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
284
|
+
...(opts.effort !== undefined ? { effort: opts.effort } : {}),
|
|
285
|
+
...(opts.mode ? { mode: opts.mode } : {}),
|
|
286
|
+
});
|
|
287
|
+
if (!updated)
|
|
288
|
+
throw new Error(`session not found: ${id}`);
|
|
289
|
+
}
|
|
290
|
+
const auth = loadAuth(this.home);
|
|
291
|
+
const apiKey = resolveApiKey(state.provider, auth);
|
|
292
|
+
if (!apiKey && providerNeedsKey(state.provider)) {
|
|
293
|
+
throw new Error(`missing API key for provider "${state.provider}" — set ${getProvider(state.provider)?.envVars.join(" or ") ?? "its env var"} or paste one via the TUI /provider command`);
|
|
294
|
+
}
|
|
295
|
+
if (!state.model)
|
|
296
|
+
throw new Error(`no model selected for provider "${state.provider}"`);
|
|
297
|
+
}
|
|
298
|
+
// Fire-and-forget (like App.submit): the returned promise settles when the
|
|
299
|
+
// turn ends, but callers normally don't await it — progress arrives as
|
|
300
|
+
// events. Rejects only when the turn cannot start (unknown session, busy,
|
|
301
|
+
// missing key); in-turn failures surface as error/cancelled events, never
|
|
302
|
+
// as rejections, so one HTTP request maps to one turn lifecycle.
|
|
303
|
+
async sendMessage(id, content, opts) {
|
|
304
|
+
this.validateTurnStart(id, content, opts);
|
|
305
|
+
const record = getSession(id, this.home);
|
|
306
|
+
if (!record)
|
|
307
|
+
throw new Error(`session not found: ${id}`);
|
|
308
|
+
const text = content.trim();
|
|
309
|
+
const state = this.stateFor(record);
|
|
310
|
+
const auth = loadAuth(this.home);
|
|
311
|
+
const provider = state.provider;
|
|
312
|
+
const model = state.model;
|
|
313
|
+
const apiKey = resolveApiKey(provider, auth);
|
|
314
|
+
const baseURL = getStoredBaseURL(auth, provider);
|
|
315
|
+
// SUBMIT STAGE 2/3 — context assembly (pre-rollback scope): refresh the
|
|
316
|
+
// pinned env block once per turn; it survives a failed-turn rollback.
|
|
317
|
+
const first = state.history[0];
|
|
318
|
+
if (first?.role === "system" && typeof first.content === "string") {
|
|
319
|
+
state.history[0] = {
|
|
320
|
+
role: "system",
|
|
321
|
+
content: withEnvBlock(first.content),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
// SUBMIT STAGE 3/3 — loop entry (post-rollback scope): everything from
|
|
325
|
+
// here rolls back on failure/cancel.
|
|
326
|
+
const rollbackTo = state.history.length;
|
|
327
|
+
const turnsRollbackTo = state.turns.length;
|
|
328
|
+
state.busy = true;
|
|
329
|
+
const controller = new AbortController();
|
|
330
|
+
state.controller = controller;
|
|
331
|
+
state.lastPartial = "";
|
|
332
|
+
state.history.push({ role: "user", content: text });
|
|
333
|
+
this.pushTurn(state, { role: "user", content: text });
|
|
334
|
+
try {
|
|
335
|
+
const reply = await runAgenticLoopForProvider(provider, apiKey, model, state.history, {
|
|
336
|
+
approve: (name, args) => this.approve(state, name, args),
|
|
337
|
+
askUser: (question, options, allowCustom) => this.askBrowser(state, question, options, allowCustom === true),
|
|
338
|
+
execute: (name, args) => this.guardedExecute(state, name, args),
|
|
339
|
+
reasoningEffort: state.effort,
|
|
340
|
+
baseURL,
|
|
341
|
+
signal: controller.signal,
|
|
342
|
+
// Token/thinking/phase/tool-identity stream through the turnEvents
|
|
343
|
+
// sink ONLY (the loop invokes callbacks AND sink for those facts, so
|
|
344
|
+
// setting both would double-emit). Callbacks below cover the facts
|
|
345
|
+
// with no sink kind: tool deltas, warnings, usage, reasoning labels,
|
|
346
|
+
// and committed tool activity.
|
|
347
|
+
onToolDelta: (name, index) => this.emit(state, "tool_delta", { name, index }),
|
|
348
|
+
onWarning: (message) => {
|
|
349
|
+
this.pushTurn(state, { role: "tool", content: `⚠ ${message}` });
|
|
350
|
+
this.emit(state, "warning", { message });
|
|
351
|
+
},
|
|
352
|
+
onUsage: (u) => {
|
|
353
|
+
state.usageTotals = accumulateUsage(state.usageTotals, u);
|
|
354
|
+
this.emit(state, "usage", { usage: { ...u } });
|
|
355
|
+
},
|
|
356
|
+
onReasoning: (reasoning) => this.emit(state, "reasoning", { reasoning }),
|
|
357
|
+
onToolActivity: (label, result, isError) => {
|
|
358
|
+
this.pushTurn(state, { role: "tool", content: label, ...(isError ? { error: true } : {}) });
|
|
359
|
+
this.emit(state, "tool_activity", { label, result, isError });
|
|
360
|
+
},
|
|
361
|
+
// Commit seam: real name + effective args + result for EVERY
|
|
362
|
+
// committed call (read-only tools included — they never consult
|
|
363
|
+
// approve). Observer-only: returning undefined keeps the committed
|
|
364
|
+
// result byte-identical. Result text is capped (see
|
|
365
|
+
// truncateEventText); the full length rides along honestly.
|
|
366
|
+
onToolResult: (input) => {
|
|
367
|
+
const capped = truncateEventText(input.result);
|
|
368
|
+
this.emit(state, "tool_result", {
|
|
369
|
+
name: input.name,
|
|
370
|
+
args: { ...input.args },
|
|
371
|
+
result: capped.text,
|
|
372
|
+
isError: input.isError,
|
|
373
|
+
truncated: capped.truncated,
|
|
374
|
+
resultChars: capped.chars,
|
|
375
|
+
});
|
|
376
|
+
this.emitFileDiff(state, input.name, input.args, input.isError);
|
|
377
|
+
return undefined;
|
|
378
|
+
},
|
|
379
|
+
turnEvents: {
|
|
380
|
+
onToken: (t) => {
|
|
381
|
+
state.lastPartial = t;
|
|
382
|
+
this.emit(state, "token", { text: t });
|
|
383
|
+
},
|
|
384
|
+
onThinking: (t) => this.emit(state, "thinking", { text: t }),
|
|
385
|
+
onPhase: (phase, detail) => this.emit(state, "phase", { phase, detail: detail ?? "" }),
|
|
386
|
+
onToolStarted: (info) => this.emit(state, "tool_started", {
|
|
387
|
+
toolCallId: info.toolCallId,
|
|
388
|
+
name: info.name,
|
|
389
|
+
index: info.index,
|
|
390
|
+
}),
|
|
391
|
+
onToolFinished: (info) => this.emit(state, "tool_finished", {
|
|
392
|
+
toolCallId: info.toolCallId,
|
|
393
|
+
name: info.name,
|
|
394
|
+
isError: info.isError,
|
|
395
|
+
}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
this.pushTurn(state, { role: "assistant", content: reply });
|
|
399
|
+
this.emit(state, "done", { reply });
|
|
400
|
+
this.persist(state, id);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
const cancelled = err instanceof LoopCancelledError ||
|
|
404
|
+
(err instanceof Error && err.name === "LoopCancelledError") ||
|
|
405
|
+
controller.signal.aborted;
|
|
406
|
+
// Rollback: the turn never happened (same splice contract as the TUI).
|
|
407
|
+
state.history.splice(rollbackTo);
|
|
408
|
+
state.turns.splice(turnsRollbackTo);
|
|
409
|
+
if (cancelled) {
|
|
410
|
+
this.pushTurn(state, { role: "tool", content: cancelledTurnLine() });
|
|
411
|
+
this.emit(state, "cancelled", { notice: cancelledTurnLine() });
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
// Failed (not cancelled): the streamed answer so far commits as a
|
|
415
|
+
// marked partial row BEFORE the error, so already-read output
|
|
416
|
+
// survives. History stays rolled back (the model never sees it) —
|
|
417
|
+
// same contract as the TUI.
|
|
418
|
+
const partial = state.lastPartial.trim();
|
|
419
|
+
if (partial) {
|
|
420
|
+
this.pushTurn(state, {
|
|
421
|
+
role: "assistant",
|
|
422
|
+
content: `${partial}\n\n(request failed before completing — partial output preserved)`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
426
|
+
this.emit(state, "error", { message });
|
|
427
|
+
}
|
|
428
|
+
// Rolled-back turns never persist — the last good save stays intact.
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
state.busy = false;
|
|
432
|
+
state.controller = null;
|
|
433
|
+
state.pendingApproval = null;
|
|
434
|
+
state.pendingQuestion = null;
|
|
435
|
+
state.lastPartial = "";
|
|
436
|
+
state.fileOps = [];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// ---- catalogs (no secrets cross these) ----
|
|
440
|
+
listProviders() {
|
|
441
|
+
const auth = loadAuth(this.home);
|
|
442
|
+
return PROVIDERS.map((p) => ({
|
|
443
|
+
id: p.id,
|
|
444
|
+
name: p.name,
|
|
445
|
+
defaultModel: p.defaultModel,
|
|
446
|
+
fallbackModels: [...p.fallbackModels],
|
|
447
|
+
needsKey: providerNeedsKey(p.id),
|
|
448
|
+
hasKey: resolveApiKey(p.id, auth).length > 0,
|
|
449
|
+
notes: p.notes,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
listTools() {
|
|
453
|
+
return allToolDefinitions().map((t) => ({
|
|
454
|
+
name: t.function.name,
|
|
455
|
+
description: t.function.description,
|
|
456
|
+
needsApproval: needsApproval(t.function.name),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
// ---- internals ----
|
|
460
|
+
stateFor(record) {
|
|
461
|
+
let state = this.states.get(record.id);
|
|
462
|
+
if (!state) {
|
|
463
|
+
state = {
|
|
464
|
+
history: record.history.map((m) => ({ ...m })),
|
|
465
|
+
turns: record.turns.map((t) => ({ ...t })),
|
|
466
|
+
provider: record.provider,
|
|
467
|
+
model: record.model,
|
|
468
|
+
effort: record.effort,
|
|
469
|
+
mode: record.mode,
|
|
470
|
+
usageTotals: record.usageTotals ? { ...record.usageTotals } : null,
|
|
471
|
+
busy: false,
|
|
472
|
+
controller: null,
|
|
473
|
+
pendingApproval: null,
|
|
474
|
+
pendingQuestion: null,
|
|
475
|
+
alwaysAllowed: new Set(),
|
|
476
|
+
listeners: new Set(),
|
|
477
|
+
eventLog: [],
|
|
478
|
+
seq: 0,
|
|
479
|
+
lastPartial: "",
|
|
480
|
+
fileOps: [],
|
|
481
|
+
};
|
|
482
|
+
this.states.set(record.id, state);
|
|
483
|
+
}
|
|
484
|
+
return state;
|
|
485
|
+
}
|
|
486
|
+
syncStateFromRecord(state, record) {
|
|
487
|
+
state.history = record.history.map((m) => ({ ...m }));
|
|
488
|
+
state.turns = record.turns.map((t) => ({ ...t }));
|
|
489
|
+
state.provider = record.provider;
|
|
490
|
+
state.model = record.model;
|
|
491
|
+
state.effort = record.effort;
|
|
492
|
+
state.mode = record.mode;
|
|
493
|
+
state.usageTotals = record.usageTotals ? { ...record.usageTotals } : null;
|
|
494
|
+
}
|
|
495
|
+
emit(state, kind, data) {
|
|
496
|
+
state.seq += 1;
|
|
497
|
+
const event = createWebEvent(state.seq, kind, data);
|
|
498
|
+
state.eventLog.push(event);
|
|
499
|
+
if (state.eventLog.length > EVENT_LOG_CAP) {
|
|
500
|
+
state.eventLog.splice(0, state.eventLog.length - EVENT_LOG_CAP);
|
|
501
|
+
}
|
|
502
|
+
for (const listener of [...state.listeners]) {
|
|
503
|
+
try {
|
|
504
|
+
listener(event);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// a throwing listener must not break the turn or other clients
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return event;
|
|
511
|
+
}
|
|
512
|
+
pushTurn(state, turn) {
|
|
513
|
+
state.turns.push(turn);
|
|
514
|
+
this.emit(state, "message", { ...turn });
|
|
515
|
+
}
|
|
516
|
+
// Commit-time file diffs: consumes one queued pre-execution entry per
|
|
517
|
+
// write/edit commit (FIFO by name — the loop commits in call order) and
|
|
518
|
+
// emits a file_diff with pre-computed unified hunks + side-by-side rows
|
|
519
|
+
// from the shared engine (src/ui/diff.ts, same as the TUI preview).
|
|
520
|
+
// Failed calls consume without emitting (nothing changed on disk).
|
|
521
|
+
// edit AFTER bytes come from a post-commit display read (capped); write
|
|
522
|
+
// AFTER bytes are the committed content arg. Display reads only — the
|
|
523
|
+
// WebUI never executes a tool to satisfy the UI.
|
|
524
|
+
emitFileDiff(state, name, args, isError) {
|
|
525
|
+
if (name !== "write" && name !== "edit")
|
|
526
|
+
return;
|
|
527
|
+
let slot = -1;
|
|
528
|
+
for (let i = 0; i < state.fileOps.length; i++) {
|
|
529
|
+
if (state.fileOps[i].name === name) {
|
|
530
|
+
slot = i;
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (slot === -1)
|
|
535
|
+
return;
|
|
536
|
+
const evidence = state.fileOps.splice(slot, 1)[0];
|
|
537
|
+
if (isError)
|
|
538
|
+
return;
|
|
539
|
+
const toolPath = typeof args["path"] === "string" ? args["path"] : evidence.path;
|
|
540
|
+
let newFull = evidence.newFull;
|
|
541
|
+
if (name === "edit") {
|
|
542
|
+
try {
|
|
543
|
+
newFull = readCappedForDiff(path.resolve(process.cwd(), toolPath));
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
newFull = null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (evidence.oldFull === null && newFull === null)
|
|
550
|
+
return;
|
|
551
|
+
const oldCut = evidence.oldFull === null ? null : cutAtNewline(evidence.oldFull);
|
|
552
|
+
const newCut = newFull === null ? null : cutAtNewline(newFull);
|
|
553
|
+
const oldText = oldCut === null ? null : oldCut.text;
|
|
554
|
+
const newText = newCut === null ? "" : newCut.text;
|
|
555
|
+
let hunks = [];
|
|
556
|
+
let rows = [];
|
|
557
|
+
let adds = 0;
|
|
558
|
+
let dels = 0;
|
|
559
|
+
let rowsTruncated = false;
|
|
560
|
+
try {
|
|
561
|
+
const diff = computeDiff(oldText, newText);
|
|
562
|
+
if (!diff.skipped) {
|
|
563
|
+
hunks = diff.hunks;
|
|
564
|
+
adds = diff.adds;
|
|
565
|
+
dels = diff.dels;
|
|
566
|
+
}
|
|
567
|
+
const sbs = computeSideBySide(oldText, newText);
|
|
568
|
+
if (sbs.kind === "diff") {
|
|
569
|
+
adds = sbs.adds;
|
|
570
|
+
dels = sbs.dels;
|
|
571
|
+
rows = sbs.rows.slice(0, FILE_DIFF_ROWS_CAP);
|
|
572
|
+
rowsTruncated = sbs.rows.length > rows.length;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
this.emit(state, "file_diff", {
|
|
579
|
+
path: toolPath,
|
|
580
|
+
op: evidence.op,
|
|
581
|
+
lang: previewLangFromPath(toolPath),
|
|
582
|
+
adds,
|
|
583
|
+
dels,
|
|
584
|
+
isNewFile: oldText === null,
|
|
585
|
+
hunks,
|
|
586
|
+
rows,
|
|
587
|
+
rowsTruncated,
|
|
588
|
+
truncated: (oldCut?.truncated ?? false) || (newCut?.truncated ?? false),
|
|
589
|
+
oldChars: evidence.oldFull === null ? 0 : evidence.oldFull.length,
|
|
590
|
+
newChars: newFull === null ? 0 : newFull.length,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
persist(state, id) {
|
|
594
|
+
const updated = updateSession(id, {
|
|
595
|
+
provider: state.provider,
|
|
596
|
+
model: state.model,
|
|
597
|
+
effort: state.effort,
|
|
598
|
+
mode: state.mode,
|
|
599
|
+
usageTotals: state.usageTotals,
|
|
600
|
+
history: state.history.map((m) => ({ ...m })),
|
|
601
|
+
turns: state.turns.map((t) => ({ ...t })),
|
|
602
|
+
}, this.home);
|
|
603
|
+
if (updated)
|
|
604
|
+
this.syncStateFromRecord(state, updated);
|
|
605
|
+
}
|
|
606
|
+
async approve(state, name, args) {
|
|
607
|
+
if (state.controller?.signal.aborted)
|
|
608
|
+
throw new LoopCancelledError();
|
|
609
|
+
// Pre-execution file evidence for write/edit (all outcomes, including
|
|
610
|
+
// deny: the commit seam below consumes one queue entry per committed
|
|
611
|
+
// call, so every queued entry pairs exactly once). The approval preview
|
|
612
|
+
// already pre-read the write BEFORE bytes — reused, never re-read.
|
|
613
|
+
const stagedDiff = name === "write" || name === "edit" ? previewDiffForApproval(name, args) : null;
|
|
614
|
+
if ((name === "write" || name === "edit") && typeof args["path"] === "string") {
|
|
615
|
+
const toolPath = args["path"];
|
|
616
|
+
if (name === "write") {
|
|
617
|
+
state.fileOps.push({
|
|
618
|
+
name,
|
|
619
|
+
path: toolPath,
|
|
620
|
+
op: classifyWriteOp(stagedDiff?.oldText ?? null),
|
|
621
|
+
oldFull: stagedDiff?.oldText ?? null,
|
|
622
|
+
newFull: typeof args["content"] === "string" ? args["content"] : null,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
let oldFull = null;
|
|
627
|
+
try {
|
|
628
|
+
oldFull = readCappedForDiff(path.resolve(process.cwd(), toolPath));
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
oldFull = null;
|
|
632
|
+
}
|
|
633
|
+
state.fileOps.push({ name, path: toolPath, op: "modified", oldFull, newFull: null });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const verdict = decidePolicy(name, args, {
|
|
637
|
+
mode: state.mode,
|
|
638
|
+
trustAll: false,
|
|
639
|
+
rules: [],
|
|
640
|
+
alwaysAllowed: state.alwaysAllowed,
|
|
641
|
+
skillGrants: new Set(),
|
|
642
|
+
approvalGated: needsApproval(name),
|
|
643
|
+
});
|
|
644
|
+
if (verdict.kind === "deny") {
|
|
645
|
+
this.emit(state, "approval_resolved", { name, decision: "no", via: "deny" });
|
|
646
|
+
this.emit(state, "tool_call", {
|
|
647
|
+
name,
|
|
648
|
+
args: { ...args },
|
|
649
|
+
description: describeToolCall(name, args),
|
|
650
|
+
decision: "no",
|
|
651
|
+
via: "deny",
|
|
652
|
+
});
|
|
653
|
+
return "no";
|
|
654
|
+
}
|
|
655
|
+
if (verdict.kind === "allow") {
|
|
656
|
+
this.emit(state, "tool_call", {
|
|
657
|
+
name,
|
|
658
|
+
args: { ...args },
|
|
659
|
+
description: describeToolCall(name, args),
|
|
660
|
+
decision: "once",
|
|
661
|
+
via: verdict.via,
|
|
662
|
+
});
|
|
663
|
+
return "once";
|
|
664
|
+
}
|
|
665
|
+
// Prompt: block the turn on the browser. Cancel wins the race (same as
|
|
666
|
+
// the TUI's abort listener on the approval promise).
|
|
667
|
+
const description = describeToolCall(name, args);
|
|
668
|
+
const diff = stagedDiff;
|
|
669
|
+
approvalIdCounter += 1;
|
|
670
|
+
const approvalId = `apr_${approvalIdCounter}`;
|
|
671
|
+
const signal = state.controller?.signal ?? null;
|
|
672
|
+
if (signal?.aborted)
|
|
673
|
+
throw new LoopCancelledError();
|
|
674
|
+
return new Promise((resolve, reject) => {
|
|
675
|
+
const pending = {
|
|
676
|
+
id: approvalId,
|
|
677
|
+
name,
|
|
678
|
+
args: { ...args },
|
|
679
|
+
description,
|
|
680
|
+
diff,
|
|
681
|
+
// The browser's decision lands here (via resolveApproval): record
|
|
682
|
+
// the tool_call with it, so the timeline shows user-resolved calls
|
|
683
|
+
// exactly like automatic ones (provenance via "prompt").
|
|
684
|
+
resolve: (decision) => {
|
|
685
|
+
this.emit(state, "tool_call", {
|
|
686
|
+
name,
|
|
687
|
+
args: { ...args },
|
|
688
|
+
description,
|
|
689
|
+
decision,
|
|
690
|
+
via: "prompt",
|
|
691
|
+
});
|
|
692
|
+
resolve(decision);
|
|
693
|
+
},
|
|
694
|
+
reject,
|
|
695
|
+
};
|
|
696
|
+
state.pendingApproval = pending;
|
|
697
|
+
this.emit(state, "approval_request", {
|
|
698
|
+
id: approvalId,
|
|
699
|
+
name,
|
|
700
|
+
args: { ...args },
|
|
701
|
+
description,
|
|
702
|
+
diff,
|
|
703
|
+
});
|
|
704
|
+
if (signal) {
|
|
705
|
+
const onAbort = () => {
|
|
706
|
+
if (state.pendingApproval === pending)
|
|
707
|
+
state.pendingApproval = null;
|
|
708
|
+
reject(new LoopCancelledError());
|
|
709
|
+
};
|
|
710
|
+
if (signal.aborted)
|
|
711
|
+
onAbort();
|
|
712
|
+
else
|
|
713
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
guardedExecute(state, name, args) {
|
|
718
|
+
if (state.mode === "plan" && needsApproval(name)) {
|
|
719
|
+
return Promise.resolve(planModeRefusal(name));
|
|
720
|
+
}
|
|
721
|
+
// Session cwd is process.cwd() at creation (see createWebSession), same
|
|
722
|
+
// value the TUI executes under.
|
|
723
|
+
return executeTool(name, args, process.cwd());
|
|
724
|
+
}
|
|
725
|
+
askBrowser(state, question, options, allowCustom) {
|
|
726
|
+
const signal = state.controller?.signal ?? null;
|
|
727
|
+
if (signal?.aborted)
|
|
728
|
+
throw new LoopCancelledError();
|
|
729
|
+
questionIdCounter += 1;
|
|
730
|
+
const questionId = `q_${questionIdCounter}`;
|
|
731
|
+
return new Promise((resolve, reject) => {
|
|
732
|
+
const pending = {
|
|
733
|
+
id: questionId,
|
|
734
|
+
question,
|
|
735
|
+
options: [...options],
|
|
736
|
+
allowCustom,
|
|
737
|
+
resolve,
|
|
738
|
+
reject,
|
|
739
|
+
};
|
|
740
|
+
state.pendingQuestion = pending;
|
|
741
|
+
this.emit(state, "question_request", {
|
|
742
|
+
id: questionId,
|
|
743
|
+
question,
|
|
744
|
+
options: [...options],
|
|
745
|
+
allowCustom,
|
|
746
|
+
});
|
|
747
|
+
if (signal) {
|
|
748
|
+
const onAbort = () => {
|
|
749
|
+
if (state.pendingQuestion === pending)
|
|
750
|
+
state.pendingQuestion = null;
|
|
751
|
+
reject(new LoopCancelledError());
|
|
752
|
+
};
|
|
753
|
+
if (signal.aborted)
|
|
754
|
+
onAbort();
|
|
755
|
+
else
|
|
756
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Body validation for the HTTP layer (pure, unit-tested through the server
|
|
762
|
+
// tests): returns the error string for a 400, or null when the body is fine.
|
|
763
|
+
export function validateSendBody(body) {
|
|
764
|
+
if (!isRecord(body))
|
|
765
|
+
return "body must be a JSON object";
|
|
766
|
+
if (typeof body["content"] !== "string" || body["content"].trim().length === 0) {
|
|
767
|
+
return 'body.content must be a non-empty string';
|
|
768
|
+
}
|
|
769
|
+
const provider = body["provider"];
|
|
770
|
+
if (provider !== undefined && (typeof provider !== "string" || !isProviderId(provider))) {
|
|
771
|
+
return "body.provider must be a known provider id";
|
|
772
|
+
}
|
|
773
|
+
const mode = body["mode"];
|
|
774
|
+
if (mode !== undefined && mode !== "normal" && mode !== "yolo" && mode !== "plan") {
|
|
775
|
+
return "body.mode must be one of normal|yolo|plan";
|
|
776
|
+
}
|
|
777
|
+
const effort = body["effort"];
|
|
778
|
+
if (effort !== undefined &&
|
|
779
|
+
effort !== "auto" &&
|
|
780
|
+
effort !== "low" &&
|
|
781
|
+
effort !== "medium" &&
|
|
782
|
+
effort !== "high" &&
|
|
783
|
+
effort !== "max") {
|
|
784
|
+
return "body.effort must be one of auto|low|medium|high|max";
|
|
785
|
+
}
|
|
786
|
+
if (body["model"] !== undefined && typeof body["model"] !== "string") {
|
|
787
|
+
return "body.model must be a string";
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|