aegiscode 6.1.0 → 6.2.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.
@@ -0,0 +1,972 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * engine.js — the desktop LocalEngine registry (plan P1 §5.2). Transport-only:
5
+ * it owns the provider settings/credentials and routes a chat payload to the
6
+ * cloud client, the local Ollama module, or a custom OpenAI/Anthropic-
7
+ * compatible endpoint. No tier/brain/routing decisions are made here — the
8
+ * chosen class is the user's explicit selection.
9
+ *
10
+ * Since the tool-calling port it ALSO owns the agent loop (the client half of
11
+ * aegiscodex-dev's src/backend.js runProvider): every turn carries a real
12
+ * system prompt (prompt.js) and the builtin tool schemas (tools.js), and when
13
+ * a provider answers with tool calls the loop executes them in-process and
14
+ * feeds the results back for as many rounds as the model keeps calling tools
15
+ * — there is no round cap; a turn ends when the model answers with text, or
16
+ * the user cancels it (cancel()/AbortController).
17
+ *
18
+ * Because a provider can signal "finished" with no answer attached, the two
19
+ * exit paths are guarded against the empty turn (see the loop's comment):
20
+ * a `finish_reason: 'length'` completion with no text is retried once with a
21
+ * doubled budget, and a turn that ends with neither text nor a tool call is
22
+ * re-dispatched once with `tools: []` plus a nudge so the model has to write
23
+ * up what it already gathered. A turn still empty after both throws instead
24
+ * of returning a blank completion for the renderer to paint "(empty
25
+ * response)" over. The window stays
26
+ * contextIsolated + sandboxed: this module runs in the MAIN process, so the
27
+ * executor never has to be exposed to the renderer.
28
+ *
29
+ * Two turn-scoped resources ride along with the loop, mirroring
30
+ * aegiscodex-dev's runProvider exactly:
31
+ * - a lazily-started ShellSession (shell.js) that the `exec` tool shares,
32
+ * so cd/export state persists across calls within one turn instead of
33
+ * each call spawning a fresh process;
34
+ * - the `task` tool, executed as a nested `chat()` call on the chosen
35
+ * specialist preset (agents.js) rather than a local tool — a subagent
36
+ * turn with its own tool rounds, bounded by MAX_SUBAGENT_DEPTH so a
37
+ * delegation chain can't recurse forever.
38
+ *
39
+ * Pure Node + dependency-injected (aegis client, settings store, ollama,
40
+ * providers, optionally tools/prompt) so it unit-tests without Electron.
41
+ */
42
+
43
+ const { randomUUID } = require('node:crypto');
44
+ const os = require('node:os');
45
+
46
+ const toolsModule = require('./tools.js');
47
+ const promptModule = require('./prompt.js');
48
+ const { ShellSession } = require('./shell.js');
49
+ const { agentSystemPrompt, agentRoleLabel } = require('./agents.js');
50
+
51
+ /** Classes whose transport is a user-supplied endpoint + credential. */
52
+ const CUSTOM_CLASSES = Object.freeze(['openai-compat', 'anthropic']);
53
+
54
+ /**
55
+ * Depth at which the task tool stops being offered. The main chat (depth 0)
56
+ * and subagents down to depth MAX_SUBAGENT_DEPTH - 1 can all delegate, so
57
+ * legitimate hierarchical work (scan -> review -> patch, etc.) has room to
58
+ * nest without hitting a wall. Past that the tool is dropped, hard-cutting a
59
+ * runaway chain instead of letting it recurse unbounded.
60
+ */
61
+ const MAX_SUBAGENT_DEPTH = 4;
62
+
63
+ const CLASSES = [
64
+ { class: 'aegis', label: 'Aegis Cloud', kind: 'cloud' },
65
+ { class: 'ollama', label: 'Ollama (local)', kind: 'local' },
66
+ { class: 'openai-compat', label: 'Custom OpenAI-compatible', kind: 'custom' },
67
+ { class: 'anthropic', label: 'Anthropic-compatible', kind: 'custom' },
68
+ ];
69
+
70
+ /**
71
+ * Mirrors aegiscodex-dev's src/backend.js DEEPSEEK_REASONING_MODEL_RE +
72
+ * EFFORT_TOKEN_BUDGET verbatim. DeepSeek's reasoning models (deepseek-flash,
73
+ * deepseek-v4-pro, the deprecated deepseek-reasoner, and the legacy
74
+ * v4-flash/v4.1-flash aliases some configs still carry) spend part of
75
+ * max_tokens on hidden chain-of-thought before ever emitting visible
76
+ * content — DeepSeek counts reasoning tokens against the same budget as
77
+ * content. At the renderer's 4k default (index.html's max-tokens select),
78
+ * any non-trivial question can burn the whole budget reasoning and finish
79
+ * with empty content: no error, no tool calls, just a turn that "completes"
80
+ * with nothing to show for it (the empty-response bug). A user pointing the
81
+ * Custom OpenAI-compatible class straight at DeepSeek's API hits exactly
82
+ * this, so the request floors to the same effort budget aegiscodex-dev uses
83
+ * for its own direct DeepSeek calls instead of shipping whatever the
84
+ * dropdown happens to have selected.
85
+ */
86
+ const DEEPSEEK_REASONING_MODEL_RE = /^deepseek-(v4(\.\d+)?-(flash|pro)|flash|pro|reasoner)$/;
87
+ const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
88
+
89
+ /**
90
+ * Idle-stream budget for a pooled brain call ("work autonomously"). The
91
+ * generic watchdog in vendor/aegis.js kills a stream that goes 60s without a
92
+ * byte — right default for one provider call answering, wrong for a worker
93
+ * fan-out: pool_brain yields a header chunk, then stays silent until the
94
+ * FIRST worker pass *returns*, and each worker is a full reasoning-model call
95
+ * at roughly 1/(workers+1) of the effort budget. At high effort, 3 workers,
96
+ * that is a multi-thousand-token reasoning pass per worker — easily past a
97
+ * minute. Timing out there aborts a perfectly healthy autonomous turn
98
+ * mid-flight, after the server has already run and billed every worker.
99
+ *
100
+ * 15 minutes deliberately outlasts the server's OWN ceiling for that window
101
+ * (aegis1 services/pool_brain.py: NEXUS_BRAIN_WORKER_TIMEOUT, default 600s),
102
+ * because aborting first leaves the server running and billing a fan-out
103
+ * nobody will ever see. Raising that env var past ~14 minutes means raising
104
+ * this constant too; the shared client applies the same budget from the
105
+ * server's X-AEGIS-Brain response header (see client/aegis.js idleBudgetFor),
106
+ * which is what covers a fan-out the caller did not flag — test/
107
+ * autonomous-mode.test.mjs pins both halves.
108
+ */
109
+ const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
110
+
111
+ /**
112
+ * Only ever raises a too-low budget for a DeepSeek reasoning model — never
113
+ * lowers whatever the caller (renderer dropdown, or "adaptive" ceiling)
114
+ * already asked for. Everything else (non-DeepSeek models, non-reasoning
115
+ * DeepSeek ids like deepseek-chat) passes through untouched. Effort defaults
116
+ * to 'high' since custom endpoints have no effort selector of their own
117
+ * (that UI is aegis-class/autonomous-only) — matching aegiscodex-dev's own
118
+ * default effort.
119
+ */
120
+ function deepseekReasoningFloor(model, maxTokens, effort) {
121
+ if (!DEEPSEEK_REASONING_MODEL_RE.test(String(model || ''))) return maxTokens;
122
+ const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
123
+ return Math.max(Number(maxTokens) || 0, EFFORT_TOKEN_BUDGET[eff]);
124
+ }
125
+
126
+ /** Relay model entries arrive as ids or objects; keep only real model ids. */
127
+ function normalizeCatalog(models) {
128
+ if (!Array.isArray(models)) return [];
129
+ return models
130
+ .map((m) => (typeof m === 'string' ? { id: m } : m))
131
+ .filter((m) => m && m.id);
132
+ }
133
+
134
+ /**
135
+ * The Aegis Cloud catalog (`/api/v1/models`) lists every backend the pool can
136
+ * reach: per-provider ids (`openai`, `anthropic`, `groq`, `gemini`, ...) and
137
+ * six pooled-brain tier ids (`{aegis,nexus}-brain[-smart|-neo]`) that all run
138
+ * the same worker pool on the same backend model. None of that is a human's
139
+ * model choice — which providers currently hold a valid key is an ops detail
140
+ * (today: deepseek/anthropic/groq; openai and gemini drift in and out), and
141
+ * surfacing it invites picking a provider that happens to be dead right now.
142
+ * The pool already auto-routes across whichever providers are live, so the
143
+ * desktop dropdown offers exactly one entry for the "aegis" class: the
144
+ * collapsed "Nexus" brain — never the raw provider list.
145
+ */
146
+ const NEXUS_BRAIN_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
147
+ const NEXUS_LABEL = 'Nexus';
148
+
149
+ /**
150
+ * Pick the one entry standing in for the pooled brain. The server serves
151
+ * `nexus-brain` as the canonical tier (`hidden: false`, no `alias_of`) and
152
+ * marks every other spelling — `aegis-brain` and the `-smart`/`-neo` tiers —
153
+ * as `hidden: true, alias_of: "nexus-brain"`. Prefer the canonical id so the
154
+ * request travels on the name the server owns; fall back to the alias, then to
155
+ * any tier whose `alias_of` names the brain, so a renamed or trimmed catalog
156
+ * still resolves to something selectable instead of silently emptying the
157
+ * dropdown (the previous fixed-id lookup returned [] if `aegis-brain` was ever
158
+ * retired, leaving the class with no model to choose).
159
+ */
160
+ function selectBrainEntry(models) {
161
+ return (
162
+ models.find((m) => NEXUS_BRAIN_IDS.includes(m.id) && !m.hidden && m.alias_of === undefined)
163
+ || models.find((m) => NEXUS_BRAIN_IDS.includes(m.id))
164
+ || models.find((m) => m.alias_of && NEXUS_BRAIN_IDS.includes(m.alias_of))
165
+ || null
166
+ );
167
+ }
168
+
169
+ function filterAegisCatalog(models) {
170
+ const nexus = selectBrainEntry(models);
171
+ if (!nexus) return [];
172
+ // Drop the alias bookkeeping: this entry *is* the selection, so the renderer
173
+ // must never treat it as a hidden alias and filter it back out.
174
+ const { hidden, alias_of, ...rest } = nexus;
175
+ return [{ ...rest, label: NEXUS_LABEL }];
176
+ }
177
+
178
+ // ── Agent-loop helpers ──────────────────────────────────────────────────────
179
+
180
+ /** Parse a model-supplied argument blob (string or already-parsed object). */
181
+ function parseArgs(raw) {
182
+ if (raw == null) return {};
183
+ if (typeof raw === 'object') return raw;
184
+ try {
185
+ const v = JSON.parse(String(raw));
186
+ return v && typeof v === 'object' ? v : {};
187
+ } catch {
188
+ return {};
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Pull normalised tool calls out of whichever shape the transport returned:
194
+ * the shared `result.toolCalls` both local parsers emit, or a provider-native
195
+ * `choices[0].message.tool_calls` (the Aegis pool relays the upstream OpenAI
196
+ * shape verbatim).
197
+ */
198
+ function extractToolCalls(res) {
199
+ if (!res) return [];
200
+ if (Array.isArray(res.toolCalls) && res.toolCalls.length) {
201
+ return res.toolCalls
202
+ .map((c) => ({ id: c.id || '', name: c.name || '', args: parseArgs(c.args) }))
203
+ .filter((c) => c.name);
204
+ }
205
+ const msg = res.choices && res.choices[0] && res.choices[0].message;
206
+ const raw = (msg && msg.tool_calls) || [];
207
+ if (!Array.isArray(raw)) return [];
208
+ return raw
209
+ .map((tc) => {
210
+ const fn = (tc && tc.function) || {};
211
+ return { id: (tc && tc.id) || '', name: fn.name || (tc && tc.name) || '', args: parseArgs(fn.arguments) };
212
+ })
213
+ .filter((c) => c.name);
214
+ }
215
+
216
+ /** The textual content of one assistant turn (empty when it only called tools). */
217
+ function assistantText(res) {
218
+ const msg = res && res.choices && res.choices[0] && res.choices[0].message;
219
+ return (msg && typeof msg.content === 'string' && msg.content) || '';
220
+ }
221
+
222
+ /**
223
+ * The provider's stop reason for a completion ('' when it sent none). The two
224
+ * wire formats report it in different places and both have to be read: the
225
+ * OpenAI shape carries `choices[0].finish_reason` (this is what providers.js
226
+ * and vendor/aegis.js emit), while providers.js's Anthropic parser puts
227
+ * `stop_reason` on the result and never sets a finish_reason on the choice.
228
+ */
229
+ function finishReasonOf(res) {
230
+ const choice = res && res.choices && res.choices[0];
231
+ return (choice && choice.finish_reason) || (res && res.stop_reason) || '';
232
+ }
233
+
234
+ /**
235
+ * True when the provider cut the answer off at the token budget instead of
236
+ * the model choosing to stop. This is the diagnosis for the most common
237
+ * flavour of the empty turn: DeepSeek (and other reasoning models that bill
238
+ * hidden chain-of-thought against max_tokens) can spend the entire budget
239
+ * before emitting a single visible token, and the completion still arrives
240
+ * as a clean `finish_reason: 'length'` — no error, empty content. A tool
241
+ * call whose JSON was truncated mid-argument lands here too, where
242
+ * parseArgs() would otherwise silently yield `{}` and run the tool with no
243
+ * arguments, which is worse than retrying.
244
+ */
245
+ function isTruncated(res) {
246
+ const reason = finishReasonOf(res);
247
+ // 'length' is OpenAI's wording, 'max_tokens' is Anthropic's — same event.
248
+ return reason === 'length' || reason === 'max_tokens';
249
+ }
250
+
251
+ /**
252
+ * Double a budget for the one-shot truncation retry. A budget the caller set
253
+ * on purpose (the flow lane's deliberate 1024-token cap, say) is merely
254
+ * doubled — the floor only applies when nothing was set at all, so a retry
255
+ * can never silently override a small cap by an order of magnitude.
256
+ */
257
+ function doubledBudget(maxTokens) {
258
+ const n = Number(maxTokens) || 0;
259
+ return n > 0 ? n * 2 : 8192;
260
+ }
261
+
262
+ /**
263
+ * The follow-up shown to a model that ended its turn with neither text nor a
264
+ * tool call. Sent as a plain user message (never as a tool result — there is
265
+ * no pending tool call to answer) so every provider accepts it verbatim.
266
+ */
267
+ const EMPTY_TURN_NUDGE =
268
+ 'Your previous reply came back empty — it contained no answer and no tool call. ' +
269
+ 'Write your answer now, using only the information already gathered above. ' +
270
+ 'No tools are available in this reply, so do not call any: respond in plain ' +
271
+ 'prose or markdown.';
272
+
273
+ /**
274
+ * Last resort for a turn that is still empty after the synthesis pass.
275
+ *
276
+ * Throws rather than returning the blank completion. Returning it is what
277
+ * produces the renderer's undiagnosable "(empty response)" bubble, and
278
+ * synthesising fake assistant text instead would be worse: renderer/app.js
279
+ * persists whatever comes back as the assistant's own message and syncs it
280
+ * to the aegis account, so the notice would re-enter the model's context on
281
+ * the next turn as something it had said. Failing loudly leaves the turn's
282
+ * real tool log on screen, keeps the transcript honest, and names the cause
283
+ * (renderer prints `Error: <message>`).
284
+ */
285
+ function emptyTurnError({ cls, model, maxTokens, finishReason }) {
286
+ const err = new Error(
287
+ `The model returned no answer after ${cls}/${model} was asked to summarise its results ` +
288
+ `(stop reason: ${finishReason || 'none'}, max_tokens: ${maxTokens}). The token budget ` +
289
+ 'was most likely consumed before any visible text — raise the max-tokens setting, ' +
290
+ 'or lower effort.'
291
+ );
292
+ err.status = 502;
293
+ return err;
294
+ }
295
+
296
+ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBuilder, env, getConfirmMode }) {
297
+ const controllers = new Map(); // sessionId -> AbortController
298
+ const T = tools || toolsModule;
299
+ const buildSystemPrompt = (promptBuilder && promptBuilder.buildSystemPrompt) || promptModule.buildSystemPrompt;
300
+
301
+ /** "Confirm before running tools" (Settings toggle, persisted by
302
+ * lib/settings.js. Because gatedExecuteTool is called for EVERY tool round
303
+ * it is read per call, not captured once at construction: flipping the
304
+ * switch takes effect on the next tool call, with no restart.
305
+ * An explicit `getConfirmMode` factory arg wins (used by tests); then the
306
+ * settings store's own accessor; then the safe default — ON, i.e. the gate
307
+ * stays up, so a store that predates the toggle can never silently
308
+ * disable it. */
309
+ const confirmModeEnabled = () => {
310
+ if (typeof getConfirmMode === 'function') return getConfirmMode() !== false;
311
+ if (settings && typeof settings.getConfirmMode === 'function') {
312
+ const value = settings.getConfirmMode();
313
+ return value === undefined ? true : Boolean(value);
314
+ }
315
+ return true;
316
+ };
317
+
318
+ // ── Tool-call approval gate (renderer confirms exec/writeFile/editFile
319
+ // before they run) ─────────────────────────────────────────────────────
320
+ //
321
+ // `sessionAllowlists` is keyed by the CONVERSATION's root session id (the
322
+ // one the renderer's `send()` mints once per thread and reuses across
323
+ // turns — see rootSessionId below), never by the per-call sessionId a
324
+ // subagent gets, so "allow for this session" reads the way the user sees
325
+ // it: one decision per open conversation, not per nested tool round.
326
+ // In-memory only, on purpose — never persisted, so a restart (or
327
+ // newChat()'s clearSessionApprovals) always starts from a clean gate.
328
+ const sessionAllowlists = new Map(); // rootSessionId -> Set<toolName>
329
+ const pendingApprovals = new Map(); // approvalId -> { resolve }
330
+
331
+ function sessionAllows(rootId, name) {
332
+ const set = sessionAllowlists.get(rootId);
333
+ return Boolean(set && set.has(name));
334
+ }
335
+
336
+ function allowForSession(rootId, name) {
337
+ if (!sessionAllowlists.has(rootId)) sessionAllowlists.set(rootId, new Set());
338
+ sessionAllowlists.get(rootId).add(name);
339
+ }
340
+
341
+ /** newChat() in the renderer calls this so a fresh conversation never
342
+ * inherits a prior thread's blanket allows. */
343
+ function clearSessionApprovals(rootSessionId) {
344
+ sessionAllowlists.delete(rootSessionId);
345
+ return { ok: true };
346
+ }
347
+
348
+ /** The renderer's approval card resolves the pending requestApproval()
349
+ * promise below. An unknown/already-answered id is a no-op — the card
350
+ * can only be clicked once (it disables itself), but a duplicate or
351
+ * late message must never throw. */
352
+ function respondApproval(approvalId, decision) {
353
+ const pending = pendingApprovals.get(approvalId);
354
+ if (!pending) return { ok: false };
355
+ pendingApprovals.delete(approvalId);
356
+ pending.resolve(decision === 'session' || decision === 'once' ? decision : 'deny');
357
+ return { ok: true };
358
+ }
359
+
360
+ /**
361
+ * Ask the renderer to approve one mutating tool call. Resolves 'once',
362
+ * 'session' or 'deny'. Sent over `rootOnDelta` (see chat()) as an
363
+ * `{ approval }` chunk so it rides the exact same streaming channel as
364
+ * tool-activity chunks — no new IPC surface needed on the push side, only
365
+ * on the reply side (respondApproval). Fails safe: no listener able to
366
+ * ever answer (no onDelta, or the turn was aborted) resolves 'deny'
367
+ * instead of hanging the tool round forever.
368
+ */
369
+ function requestApproval(rootSessionId, rootOnDelta, signal, info) {
370
+ return new Promise((resolve) => {
371
+ if (signal && signal.aborted) {
372
+ resolve('deny');
373
+ return;
374
+ }
375
+ const id = randomUUID();
376
+ let settled = false;
377
+ const onAbort = () => finish('deny');
378
+ const finish = (decision) => {
379
+ if (settled) return;
380
+ settled = true;
381
+ pendingApprovals.delete(id);
382
+ if (signal) signal.removeEventListener('abort', onAbort);
383
+ resolve(decision);
384
+ };
385
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
386
+ pendingApprovals.set(id, { resolve: finish });
387
+ if (typeof rootOnDelta !== 'function') {
388
+ finish('deny');
389
+ return;
390
+ }
391
+ rootOnDelta({
392
+ delta: '',
393
+ approval: {
394
+ id,
395
+ sessionId: rootSessionId,
396
+ tool: info.tool,
397
+ args: info.args,
398
+ diff: info.diff || null,
399
+ },
400
+ });
401
+ });
402
+ }
403
+
404
+ /**
405
+ * The gate itself: read-only tools and already-session-allowed mutating
406
+ * tools run exactly like executeTool always did. A first-time mutating
407
+ * call previews writeFile/editFile (a preview failure — e.g. old_string
408
+ * not found — is returned as the ordinary tool error, no approval prompt
409
+ * needed for a call that couldn't succeed anyway), asks the renderer, and
410
+ * on approval either applies with a fresh hash check (writeFile/editFile)
411
+ * or runs normally (exec — nothing to hash-check).
412
+ *
413
+ * Confirm mode off (Settings → "Confirm before running tools") short-circuits
414
+ * ALL of that: no preview, no approval card, no requestApproval() — the call
415
+ * runs straight through exactly like a session-allowed one, so "don't ask"
416
+ * is one switch rather than a per-tool blanket allow in every conversation.
417
+ */
418
+ async function gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal }) {
419
+ const { name, args } = call;
420
+ if (!T.MUTATING_TOOLS.has(name)) return T.executeTool(name, args, toolCtx);
421
+ if (!confirmModeEnabled()) return T.executeTool(name, args, toolCtx);
422
+ if (sessionAllows(rootSessionId, name)) return T.executeTool(name, args, toolCtx);
423
+
424
+ let preview = null;
425
+ if (name === 'writeFile' || name === 'editFile') {
426
+ preview = T.previewMutation(name, args);
427
+ if (!preview.ok) return { ok: false, error: preview.error };
428
+ }
429
+
430
+ const decision = await requestApproval(rootSessionId, rootOnDelta, signal, {
431
+ tool: name,
432
+ args,
433
+ diff: preview && preview.diff,
434
+ });
435
+
436
+ if (decision === 'deny') {
437
+ return { ok: false, error: `${name} was not executed — the user denied the request.` };
438
+ }
439
+ if (decision === 'session') allowForSession(rootSessionId, name);
440
+
441
+ if (preview) return T.applyChecked(name, args, preview);
442
+ return T.executeTool(name, args, toolCtx);
443
+ }
444
+
445
+ /**
446
+ * Custom endpoints are only usable when they are actually configured:
447
+ * a base URL is mandatory for both, and Anthropic additionally needs its own
448
+ * key (the wire format authenticates with x-api-key). Reporting them as
449
+ * always-ready made chat() POST to `${undefined}/v1/…` (defect #2).
450
+ */
451
+ function customStatus(cls) {
452
+ const cfg = settings.get(cls) || {};
453
+ const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
454
+ const hasBase = Boolean(baseURL);
455
+ const hasKey = Boolean(cfg.configured);
456
+ return {
457
+ configured: cls === 'anthropic' ? hasBase && hasKey : hasBase,
458
+ baseURL,
459
+ keyMask: cfg.keyMask || null,
460
+ };
461
+ }
462
+
463
+ async function listClasses() {
464
+ const status = await ollama.probe().catch(() => ({ running: false }));
465
+ return CLASSES.map((c) => {
466
+ if (c.class === 'ollama') return { ...c, configured: Boolean(status.running) };
467
+ if (c.class === 'aegis') {
468
+ return { ...c, configured: Boolean(aegis.apiKey) };
469
+ }
470
+ return { ...c, ...customStatus(c.class) };
471
+ });
472
+ }
473
+
474
+ async function listModels(cls) {
475
+ if (cls === 'aegis') {
476
+ const data = await aegis.listModels();
477
+ return { class: cls, models: filterAegisCatalog(normalizeCatalog(data && data.models)) };
478
+ }
479
+ if (cls === 'ollama') {
480
+ const tags = await ollama.listTags();
481
+ return { class: cls, models: tags.map((t) => ({ id: t.id })) };
482
+ }
483
+ // Custom endpoints: the model id is the *user's* choice — a provider model
484
+ // name, never a URL. Offering the configured base URL as an `id` meant that
485
+ // leaving the default selection POSTed `model: "https://api.openai.com/v1"`,
486
+ // an upstream 400 invalid-model on every call (defect B). There is nothing
487
+ // to enumerate, so the list stays empty and `needsModelId` tells the
488
+ // renderer to prompt for a typed id instead. The base URL still travels
489
+ // along for display only.
490
+ const cfg = settings.get(cls) || {};
491
+ const baseURL = typeof cfg.baseURL === 'string' ? cfg.baseURL.trim() : '';
492
+ return { class: cls, models: [], needsModelId: true, baseURL };
493
+ }
494
+
495
+ /**
496
+ * The environment facts the model needs to stop asking which OS it is on.
497
+ * Everything is best-effort: a missing field is simply omitted.
498
+ */
499
+ function envFor(payload) {
500
+ const supplied = (payload && payload.env) || {};
501
+ const base = env || {};
502
+ const pick = (key, value) => (supplied[key] != null ? supplied[key] : value);
503
+ let homedir = base.homedir;
504
+ let cwd = base.cwd;
505
+ try {
506
+ if (!homedir) homedir = os.homedir();
507
+ if (!cwd) cwd = process.cwd();
508
+ } catch {
509
+ /* keep whatever we have */
510
+ }
511
+ return {
512
+ platform: pick('platform', base.platform || process.platform),
513
+ arch: pick('arch', base.arch || process.arch),
514
+ homedir: pick('homedir', homedir),
515
+ cwd: pick('cwd', cwd),
516
+ roots: pick('roots', base.roots),
517
+ appVersion: pick('appVersion', base.appVersion),
518
+ model: pick('model', payload && payload.model),
519
+ };
520
+ }
521
+
522
+ /** One transport round for the chosen class. */
523
+ async function dispatch(cls, opts) {
524
+ if (cls === 'aegis') {
525
+ // `undefined` = "leave the model id's own default alone" (a real user
526
+ // turn on the pooled class: the selected Nexus id is what decides, so
527
+ // today's behaviour is unchanged). `false` = an explicit single provider
528
+ // call, for a pass that is a continuation rather than a new
529
+ // investigation. `true` = the autonomous fan-out.
530
+ const brainFlag = opts.singlePass ? false : opts.autonomous ? true : undefined;
531
+ return aegis.chatCompletion({
532
+ prompt: opts.prompt,
533
+ system: opts.system,
534
+ messages: opts.messages,
535
+ model: opts.model,
536
+ mode: opts.mode,
537
+ maxTokens: opts.maxTokens,
538
+ stream: opts.stream !== false,
539
+ // The pooled (Nexus) brain is streamed, and an OpenAI-compatible SSE
540
+ // stream reports no token usage unless asked. Without this the Aegis
541
+ // Cloud class — the desktop's default — was the one class that answered
542
+ // with text but never a token count, so a pooled turn's spend was
543
+ // invisible here while the same model through the MCP path reported it.
544
+ includeUsage: true,
545
+ onStream: opts.onDelta,
546
+ // Extended-reasoning trace (the fan-out's worker findings). Its own
547
+ // channel so it never counts as answer text — see vendor/aegis.js.
548
+ onReasoning: opts.onReasoning,
549
+ // A brain fan-out is silent between passes; give it room (see
550
+ // AUTONOMOUS_IDLE_TIMEOUT_MS). Undefined elsewhere -> 60s default.
551
+ idleTimeoutMs: opts.idleTimeoutMs,
552
+ signal: opts.signal,
553
+ // aegis_memory: automatic, no button — the server both reads prior
554
+ // synced memory into context AND writes this turn back to it, the
555
+ // same flag aegis-online sets. Matches aegiscodex-dev's own
556
+ // cross-session memory (auto-indexed, no manual tagging).
557
+ extra: {
558
+ aegis_memory: true,
559
+ session: opts.sessionId,
560
+ // The fan-out is opt-in per dispatch. `brain` is sent EXPLICITLY
561
+ // whenever this dispatch is not the autonomous one, because the
562
+ // model id this class sends (``nexus-brain``) enables the pooled
563
+ // brain on its own: without the flag a continuation pass — the
564
+ // doubled-budget retry, or the "write up what you already found"
565
+ // re-dispatch — silently re-ran the whole workers+1 fan-out for a
566
+ // pass whose documented cost is a single request. aegis1
567
+ // services/pool_brain.py parse_brain_request honours the opt-out.
568
+ ...(brainFlag === undefined ? {} : { brain: brainFlag }),
569
+ // An opted-out pass is also told WHICH band to run on. A single call
570
+ // on a brain model id infers its band from the id and lands on
571
+ // "fast" (the cheapest id in the pool); "brain" is the band the
572
+ // workers themselves run on (cheapest model that can think). Same
573
+ // model as the fan-out, one sample instead of four — otherwise
574
+ // dropping the fan-out would have quietly changed the model too.
575
+ ...(brainFlag === false ? { mode: 'brain' } : {}),
576
+ // Only meaningful (and only sent) alongside a running fan-out — aegis1
577
+ // services/pool_brain.py parse_brain_request reads `effort`/
578
+ // `workers` straight off the body and clamps them itself
579
+ // (EFFORT_LEVELS / MAX_WORKERS), so no client-side validation here.
580
+ // Keyed on the effective brain flag, not on `autonomous`: an opted-out
581
+ // single pass carries no fan-out tuning it cannot use.
582
+ ...(brainFlag === true && opts.effort ? { effort: opts.effort } : {}),
583
+ ...(brainFlag === true && opts.workers ? { workers: opts.workers } : {}),
584
+ // The pool forwards `tools` to the provider and returns tool_calls
585
+ // (aegis1 app.py:7765 → provider, pool_brain synthesis keeps them).
586
+ ...(opts.tools.length ? { tools: opts.tools } : {}),
587
+ ...(opts.toolChoice ? { tool_choice: opts.toolChoice } : {}),
588
+ },
589
+ });
590
+ }
591
+
592
+ if (cls === 'ollama') {
593
+ return ollama.chat({
594
+ model: opts.model,
595
+ prompt: opts.prompt,
596
+ system: opts.system,
597
+ messages: opts.messages,
598
+ maxTokens: opts.maxTokens,
599
+ signal: opts.signal,
600
+ onDelta: opts.onDelta,
601
+ ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
602
+ });
603
+ }
604
+
605
+ const common = {
606
+ baseURL: opts.cfg.baseURL,
607
+ apiKey: opts.apiKey,
608
+ model: opts.model,
609
+ prompt: opts.prompt,
610
+ system: opts.system,
611
+ messages: opts.messages,
612
+ maxTokens: opts.maxTokens,
613
+ signal: opts.signal,
614
+ onDelta: opts.onDelta,
615
+ ...(opts.tools.length ? { tools: opts.tools, toolChoice: opts.toolChoice } : {}),
616
+ };
617
+
618
+ if (cls === 'anthropic') return providers.anthropicMessages(common);
619
+ return providers.openaiCompatible(common);
620
+ }
621
+
622
+ async function chat(payload, onDelta) {
623
+ const cls = payload && payload.class;
624
+ const model = payload && payload.model;
625
+ const maxTokens = deepseekReasoningFloor(model, payload && payload.maxTokens, payload && payload.effort);
626
+ // "Work autonomously" — routes this call through aegis1's pool_brain
627
+ // worker fan-out (services/pool_brain.py: N reasoning workers + a
628
+ // synthesis pass) instead of a single provider call. UI-gated to the
629
+ // 'aegis' class only (see AUTONOMOUS_CLASS in app.js).
630
+ const autonomous = cls === 'aegis' && Boolean(payload && payload.autonomous);
631
+ const sessionId = (payload && payload.sessionId) || randomUUID();
632
+ // Recursion depth for the task tool: 0 for a real user turn, N+1 for a
633
+ // subagent spawned by depth N. Never set by an IPC caller — only by
634
+ // runSubagent's own recursive chat() call below.
635
+ const depth = Number.isInteger(payload && payload.depth) ? payload.depth : 0;
636
+ // The approval gate's identity for this whole conversation, regardless of
637
+ // depth: a real user turn defines it (defaults to its own sessionId); a
638
+ // subagent's nested chat() call always receives it explicitly from
639
+ // runSubagent below, so "allow for this session" means the same thing
640
+ // whether the call came from the top-level turn or three subagents deep.
641
+ const rootSessionId = (payload && payload.rootSessionId) || sessionId;
642
+ // Likewise, approval requests must always reach the ORIGINAL caller's
643
+ // stream — a subagent's own chat() call is invoked with a no-op onDelta
644
+ // (its tool activity/text is not streamed to the renderer), so without
645
+ // this a nested approval request would call that no-op and hang forever
646
+ // waiting for a response nobody can ever send.
647
+ const rootOnDelta = (payload && payload.rootOnDelta) || onDelta;
648
+
649
+ // A pooled brain call streams each worker's finding as extended reasoning
650
+ // before the synthesis pass writes the visible answer. Forward it on its
651
+ // own channel so the renderer can show the fan-out working instead of an
652
+ // apparently idle bubble for the whole worker phase. Uses `onDelta` (not
653
+ // rootOnDelta) on purpose: a subagent's reasoning should be suppressed
654
+ // exactly as its text already is.
655
+ const onReasoning =
656
+ typeof onDelta === 'function' ? (text) => text && onDelta({ reasoning: text }) : undefined;
657
+
658
+ const controller = new AbortController();
659
+ controllers.set(sessionId, controller);
660
+ const signal = controller.signal;
661
+
662
+ // A caller can opt out of the agent loop entirely (`tools: false`) and get
663
+ // the old single-shot turn back.
664
+ const toolsEnabled = !(payload && payload.tools === false);
665
+ const wire = cls === 'anthropic' ? 'anthropic' : 'openai';
666
+ const toolSchemas = toolsEnabled ? T.toolsFor(wire, { includeSubagent: depth < MAX_SUBAGENT_DEPTH }) : [];
667
+ const toolChoice = (payload && payload.toolChoice) || null;
668
+
669
+ const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
670
+ const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
671
+ let prompt = (payload && payload.prompt) || '';
672
+
673
+ // Lazily start ONE shell session for this turn; the exec tool shares it so
674
+ // cd/env/state persist across calls. Only spawned if exec actually runs,
675
+ // and always disposed when the turn ends.
676
+ let shell = null;
677
+ const getShell = () => shell || (shell = new ShellSession({ cwd: envFor(payload).cwd }));
678
+ const toolCtx = { getShell, signal };
679
+
680
+ try {
681
+ const cfg = cls === 'aegis' || cls === 'ollama' ? {} : settings.get(cls) || {};
682
+ const apiKey = cls === 'aegis' || cls === 'ollama' ? null : settings.rawKey(cls);
683
+
684
+ // Custom classes carry no enumerable model list (see listModels), so a
685
+ // blank id here means the user never typed one. Fail loudly in-process
686
+ // instead of shipping `model: undefined` upstream (defect B).
687
+ if (CUSTOM_CLASSES.includes(cls) && (typeof model !== 'string' || !model.trim())) {
688
+ const err = new Error(
689
+ `${cls}: a model id is required — type the provider's model name ` +
690
+ '(the base URL is not a model).'
691
+ );
692
+ err.status = 400;
693
+ throw err;
694
+ }
695
+
696
+ const base = {
697
+ cls, model, mode: payload && payload.mode, maxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
698
+ effort: payload && payload.effort,
699
+ workers: payload && payload.workers,
700
+ onReasoning,
701
+ idleTimeoutMs: autonomous ? AUTONOMOUS_IDLE_TIMEOUT_MS : undefined,
702
+ // A caller with no live streaming surface (a `--no-stream` CLI flag, a
703
+ // one-shot script) can ask for the buffered non-stream wire form
704
+ // instead. Undefined/anything but `false` keeps every existing caller
705
+ // (the desktop renderer never sets this) on the streamed path.
706
+ stream: payload && payload.stream === false ? false : true,
707
+ };
708
+
709
+ // No round cap: a model that keeps calling tools keeps going for as
710
+ // long as it wants to. The old fixed cap (12 rounds) cut off genuinely
711
+ // long research/exploration turns mid-investigation. A turn ends when
712
+ // the model answers with text, or the user cancels.
713
+ //
714
+ // Both of those exit paths need a guard, because a provider's
715
+ // "I'm finished" signal can arrive with no answer attached — which is
716
+ // exactly how the renderer came to paint "(empty response)" over a turn
717
+ // that had actually done real work:
718
+ // - truncated (finish_reason 'length'): the budget was consumed
719
+ // before any visible text (DeepSeek's hidden reasoning bills
720
+ // against max_tokens), or mid tool-call JSON. One doubled retry.
721
+ // - empty (no tool call AND no text): re-dispatch once with no tools
722
+ // and a nudge, so the model has to write up what it already found.
723
+ // Each guard fires at most once per turn, so a provider that is simply
724
+ // broken still terminates instead of looping.
725
+ let truncationRetried = false;
726
+ let synthesisDone = false;
727
+
728
+ // Token accounting for the whole TURN, not just its last round. An
729
+ // agentic turn makes one provider call per tool round, and returning only
730
+ // the final round's `usage` (what this did) reported a fraction of what
731
+ // was actually spent — the tool phase's tokens simply disappeared. Every
732
+ // dispatch is summed, including the truncation retry and the synthesis
733
+ // re-dispatch: both are real, separately billed provider calls.
734
+ const turnUsage = { calls: 0 };
735
+ const USAGE_FIELDS = ['input_tokens', 'output_tokens', 'prompt_tokens', 'completion_tokens', 'total_tokens'];
736
+ const addUsage = (res) => {
737
+ const u = res && res.usage;
738
+ if (!u || typeof u !== 'object') return;
739
+ turnUsage.calls += 1;
740
+ for (const key of USAGE_FIELDS) {
741
+ if (typeof u[key] === 'number' && Number.isFinite(u[key])) {
742
+ turnUsage[key] = (turnUsage[key] || 0) + u[key];
743
+ }
744
+ }
745
+ // A provider that reports only the split (Anthropic-compatible) has no
746
+ // total to sum, so derive one or the renderer has nothing to print.
747
+ if (typeof u.total_tokens !== 'number') {
748
+ const derived =
749
+ (u.prompt_tokens ?? u.input_tokens ?? 0) + (u.completion_tokens ?? u.output_tokens ?? 0);
750
+ if (derived) turnUsage.total_tokens = (turnUsage.total_tokens || 0) + derived;
751
+ }
752
+ };
753
+ // The turn's totals win over any single round's, so the attached usage is
754
+ // never the last round wearing the whole turn's label.
755
+ const withTurnUsage = (res) => {
756
+ if (!res || typeof res !== 'object' || !turnUsage.calls) return res;
757
+ // Built from the accumulator alone: every field already present in a
758
+ // round's usage was summed into it, so merging the last round back in
759
+ // could only reintroduce a partial number under a whole-turn label.
760
+ return { ...res, usage: { ...turnUsage } };
761
+ };
762
+
763
+ // Round 1's shorthand prompt was sent as `prompt`, not as a message, so
764
+ // any follow-up dispatch in this turn must fold it into the history
765
+ // first or the model would be shown a nudge with no question above it.
766
+ const foldPromptIntoHistory = () => {
767
+ if (prompt === '') return;
768
+ const last = history[history.length - 1];
769
+ if (!(last && last.role === 'user' && last.content === prompt)) {
770
+ history.push({ role: 'user', content: prompt });
771
+ }
772
+ prompt = '';
773
+ };
774
+
775
+ for (;;) {
776
+ const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
777
+ let res;
778
+ try {
779
+ res = await dispatch(cls, opts);
780
+ } catch (e) {
781
+ // Ollama's OpenAI shim rejects `tools` on older builds. Retrying once
782
+ // without them keeps local chat working instead of turning an
783
+ // unadvertised capability into a hard failure.
784
+ const retriable = cls === 'ollama' && toolSchemas.length && e && (e.status === 400 || /tool/i.test(e.message || ''));
785
+ if (!retriable) throw e;
786
+ res = await dispatch(cls, { ...opts, tools: [] });
787
+ }
788
+ addUsage(res);
789
+
790
+ // A cancelled turn is over. Both recoveries below exist for "the model
791
+ // said nothing", and an aborted request resolves with exactly that —
792
+ // no text — so without this guard a user pressing Esc to stop a slow
793
+ // turn immediately issued ANOTHER billed provider call (and, because
794
+ // every transport concatenates the deltas it forwards, streamed the
795
+ // partial answer a second time onto the same bubble). Measured before
796
+ // the guard: one interrupted turn == two dispatches.
797
+ if (signal.aborted) return withTurnUsage(res);
798
+
799
+ // Budget exhausted before the answer was written. Doubling it costs
800
+ // one request and converts a dead turn into a real one; a second
801
+ // 'length' result is accepted as-is so a hard-capped model can't
802
+ // spin here forever.
803
+ //
804
+ // Gated on empty text on purpose. Every transport builds `content`
805
+ // by concatenating the deltas it already forwarded to onDelta, so
806
+ // empty content means nothing was streamed and re-dispatching cannot
807
+ // double up in the renderer's live view. When text *has* arrived the
808
+ // turn is not empty — the answer is merely truncated — and a retry
809
+ // would stream it a second time onto the same bubble.
810
+ if (!truncationRetried && !assistantText(res) && isTruncated(res)) {
811
+ truncationRetried = true;
812
+ // singlePass: this retry buys *budget*, not a second investigation.
813
+ // The fan-out's workers would re-run the whole task from scratch for
814
+ // it — 3 extra reasoning passes + a synthesis — which is the opposite
815
+ // of what "one doubled request" means (and what the note above
816
+ // promises). The pass itself is unchanged apart from that.
817
+ res = await dispatch(cls, {
818
+ ...opts,
819
+ singlePass: true,
820
+ maxTokens: doubledBudget(opts.maxTokens),
821
+ });
822
+ addUsage(res);
823
+ }
824
+
825
+ const calls = toolSchemas.length ? extractToolCalls(res) : [];
826
+ if (!calls.length) {
827
+ if (assistantText(res) || synthesisDone || !toolsEnabled) return withTurnUsage(res);
828
+ // The model stopped without calling a tool and without saying
829
+ // anything. Force the summary out of the context it already holds
830
+ // instead of handing the renderer a blank completion. Skipped when
831
+ // the caller opted out of the agent loop (`tools: false`): there is
832
+ // no gathered context to rescue, so a bare completion is just that.
833
+ synthesisDone = true;
834
+ foldPromptIntoHistory();
835
+ history.push({ role: 'user', content: EMPTY_TURN_NUDGE });
836
+ // singlePass: same reasoning as the truncation retry above, and it is
837
+ // the same comment's literal promise ("force the summary out of the
838
+ // context it already holds"). Escalating a write-up back into the
839
+ // worker fan-out asked three fresh workers to redo an investigation
840
+ // whose findings are already in `history`, at 4x the cost, to produce
841
+ // a paragraph the model had all the material for.
842
+ res = await dispatch(cls, {
843
+ ...opts,
844
+ singlePass: true,
845
+ messages: history,
846
+ prompt: '',
847
+ tools: [],
848
+ });
849
+ addUsage(res);
850
+ if (!assistantText(res)) {
851
+ throw emptyTurnError({
852
+ cls,
853
+ model,
854
+ maxTokens: opts.maxTokens,
855
+ finishReason: finishReasonOf(res),
856
+ });
857
+ }
858
+ return withTurnUsage(res);
859
+ }
860
+
861
+ foldPromptIntoHistory();
862
+
863
+ // Thread the assistant turn (its tool_calls) and each result back in
864
+ // the shapes both wire formats accept (providers.js normalises them).
865
+ history.push({
866
+ role: 'assistant',
867
+ content: assistantText(res),
868
+ tool_calls: calls.map((c) => ({
869
+ id: c.id,
870
+ type: 'function',
871
+ function: { name: c.name, arguments: JSON.stringify(c.args) },
872
+ })),
873
+ });
874
+
875
+ for (const call of calls) {
876
+ const result = call.name === T.SUBAGENT_TOOL
877
+ ? await runSubagent(call.args, {
878
+ cls, model, maxTokens, mode: payload && payload.mode, parentSignal: signal, depth, rootSessionId, rootOnDelta,
879
+ })
880
+ : await gatedExecuteTool(call, { toolCtx, rootSessionId, rootOnDelta, signal });
881
+ // A subagent's spend rides back on its tool result (see runSubagent).
882
+ if (result && result.usage) addUsage({ usage: result.usage });
883
+ if (onDelta) onDelta({ delta: '', tool: { name: call.name, args: call.args, ok: result.ok } });
884
+ history.push({
885
+ role: 'tool',
886
+ tool_call_id: call.id,
887
+ name: call.name,
888
+ content: T.toolResultText(result),
889
+ });
890
+ }
891
+ }
892
+ } finally {
893
+ if (shell) shell.dispose();
894
+ controllers.delete(sessionId);
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Run a `task` tool call as a subagent: a nested chat() turn on the same
900
+ * class/model, primed with the chosen specialist's system prompt (agents.js)
901
+ * and its own tool access (including task, until MAX_SUBAGENT_DEPTH cuts
902
+ * it off), returning the subagent's final text as the tool result. Never
903
+ * throws — resolves { ok, output } or { ok:false, error }, matching
904
+ * tools.js's executor contract so the caller treats it identically.
905
+ */
906
+ async function runSubagent(
907
+ { description, subagent_type, prompt: subPrompt } = {},
908
+ { cls, model, maxTokens, mode, parentSignal, depth, rootSessionId, rootOnDelta } = {}
909
+ ) {
910
+ const task = String(subPrompt || description || '').trim();
911
+ if (!task) return { ok: false, error: 'task requires a prompt' };
912
+ const label = subagent_type && subagent_type !== 'general' ? agentRoleLabel(subagent_type) : 'general';
913
+ const system = agentSystemPrompt(subagent_type);
914
+ const subSessionId = randomUUID();
915
+
916
+ // Aborting the parent turn must also stop a running subagent instead of
917
+ // leaving it to finish on its own (or sit out the whole turn timeout).
918
+ let onParentAbort;
919
+ if (parentSignal) {
920
+ if (parentSignal.aborted) return { ok: false, error: 'aborted' };
921
+ onParentAbort = () => cancel(subSessionId);
922
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
923
+ }
924
+
925
+ try {
926
+ // rootSessionId/rootOnDelta ride along explicitly (see chat()) so the
927
+ // subagent's own mutating tool calls still gate through the SAME
928
+ // approval card the user sees for the top-level turn, instead of
929
+ // silently hanging behind this call's no-op onDelta below.
930
+ const res = await chat(
931
+ {
932
+ class: cls, model, maxTokens, mode, system, prompt: task, sessionId: subSessionId, depth: (depth || 0) + 1,
933
+ rootSessionId, rootOnDelta,
934
+ },
935
+ () => {}
936
+ );
937
+ const text = assistantText(res);
938
+ // The subagent's tokens were billed to the same account as the parent
939
+ // turn, so its usage travels back on the tool result and is summed into
940
+ // the parent's total. Attached even on the no-output branch: a subagent
941
+ // that burned a full context and said nothing is precisely the spend the
942
+ // user most needs to see.
943
+ const spent = res && res.usage ? { usage: res.usage } : {};
944
+ return text
945
+ ? { ok: true, output: text, ...spent }
946
+ : { ok: false, error: `subagent (${label}) produced no output`, ...spent };
947
+ } catch (e) {
948
+ return { ok: false, error: `subagent (${label}) failed: ${e && e.message ? e.message : e}` };
949
+ } finally {
950
+ if (parentSignal && onParentAbort) parentSignal.removeEventListener('abort', onParentAbort);
951
+ }
952
+ }
953
+
954
+ function cancel(sessionId) {
955
+ const controller = controllers.get(sessionId);
956
+ if (controller) controller.abort();
957
+ return { ok: Boolean(controller) };
958
+ }
959
+
960
+ return {
961
+ CLASSES,
962
+ listClasses,
963
+ listModels,
964
+ chat,
965
+ cancel,
966
+ respondApproval,
967
+ clearSessionApprovals,
968
+ settings,
969
+ };
970
+ }
971
+
972
+ module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs };