neoctl 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +35 -9
  2. package/dist/agents/local-agent-task.js +2 -1
  3. package/dist/agents/local-agent-task.js.map +1 -1
  4. package/dist/core/query-engine.d.ts +2 -0
  5. package/dist/core/query-engine.js +20 -0
  6. package/dist/core/query-engine.js.map +1 -1
  7. package/dist/core/query.js +34 -1
  8. package/dist/core/query.js.map +1 -1
  9. package/dist/core/smoke-core-loop.js +34 -3
  10. package/dist/core/smoke-core-loop.js.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/model/config.d.ts +5 -2
  15. package/dist/model/config.js +21 -1
  16. package/dist/model/config.js.map +1 -1
  17. package/dist/model/context-window.js +1 -0
  18. package/dist/model/context-window.js.map +1 -1
  19. package/dist/model/env.js +10 -6
  20. package/dist/model/env.js.map +1 -1
  21. package/dist/model/kimi-adapter.d.ts +29 -0
  22. package/dist/model/kimi-adapter.js +108 -0
  23. package/dist/model/kimi-adapter.js.map +1 -0
  24. package/dist/model/model-metadata.json +51 -2
  25. package/dist/model/openai-chat-mapper.d.ts +1 -0
  26. package/dist/model/openai-chat-mapper.js +7 -3
  27. package/dist/model/openai-chat-mapper.js.map +1 -1
  28. package/dist/model/provider-factory.js +16 -0
  29. package/dist/model/provider-factory.js.map +1 -1
  30. package/dist/open-directory.d.ts +1 -0
  31. package/dist/open-directory.js +26 -0
  32. package/dist/open-directory.js.map +1 -0
  33. package/dist/paths.d.ts +7 -0
  34. package/dist/paths.js +12 -0
  35. package/dist/paths.js.map +1 -0
  36. package/dist/repl/commands.d.ts +4 -0
  37. package/dist/repl/commands.js +6 -0
  38. package/dist/repl/commands.js.map +1 -1
  39. package/dist/repl/index.js +366 -95
  40. package/dist/repl/index.js.map +1 -1
  41. package/dist/session/session-store.js +2 -2
  42. package/dist/session/session-store.js.map +1 -1
  43. package/dist/tips.d.ts +10 -0
  44. package/dist/tips.js +168 -0
  45. package/dist/tips.js.map +1 -0
  46. package/dist/web/html.d.ts +1 -0
  47. package/dist/web/html.js +841 -0
  48. package/dist/web/html.js.map +1 -0
  49. package/dist/web/index.d.ts +2 -0
  50. package/dist/web/index.js +1754 -0
  51. package/dist/web/index.js.map +1 -0
  52. package/package.json +7 -1
  53. package/scripts/build-standalone.mjs +139 -0
@@ -0,0 +1,1754 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { QueryEngine } from "../core/query-engine.js";
8
+ import { InMemoryAppState } from "../app/app-state.js";
9
+ import { loadDefaultDotEnvFiles } from "../model/env.js";
10
+ import { readModelProviderConfig } from "../model/config.js";
11
+ import { findModelMetadata, loadModelCatalog, reasoningEffortsForModel, resolveContextWindowTokens } from "../model/context-window.js";
12
+ import { CommunicationLogger, LoggingModelGateway } from "../model/communication-logger.js";
13
+ import { createModelGatewayFromConfig, createModelGatewayFromProcessEnv } from "../model/provider-factory.js";
14
+ import { ToolRegistry } from "../tools/registry.js";
15
+ import { editTool, writeTool } from "../tools/builtins/edit-tool.js";
16
+ import { createExecTool } from "../tools/builtins/exec-tool.js";
17
+ import { listDirectoryTool, readFileTool } from "../tools/builtins/filesystem-tools.js";
18
+ import { grepTool } from "../tools/builtins/grep-tool.js";
19
+ import { searchTool } from "../tools/builtins/search-tool.js";
20
+ import { planTool } from "../tools/builtins/plan-tool.js";
21
+ import { createAgentTool, resumeAgentTask } from "../agents/agent-tool.js";
22
+ import { createTaskTools } from "../tasks/task-tools.js";
23
+ import { TaskStore } from "../tasks/task-store.js";
24
+ import { parseReplCommand, helpText, replCommandDefinitions } from "../repl/commands.js";
25
+ import { writeSessionMarkdownExport } from "../session/session-export.js";
26
+ import { WEB_HTML } from "./html.js";
27
+ import { appTips, formatTipLine, initialTipIndex, tipAt } from "../tips.js";
28
+ import { openDirectory } from "../open-directory.js";
29
+ class SessionUsageTracker {
30
+ totals = emptyUsageTotals();
31
+ lastUsage;
32
+ add(usage) {
33
+ if (usage === this.lastUsage)
34
+ return;
35
+ this.lastUsage = usage;
36
+ const inputTokens = usageTokenValue(usage.inputTokens);
37
+ const outputTokens = usageTokenValue(usage.outputTokens);
38
+ const reportedTotalTokens = usageTokenValue(usage.totalTokens);
39
+ const computedTotalTokens = reportedTotalTokens ?? sumUsageTokens(inputTokens, outputTokens);
40
+ const reasoningTokens = usageTokenValue(usage.reasoningTokens);
41
+ const cachedTokens = usageTokenValue(usage.cachedTokens);
42
+ if (inputTokens === undefined && outputTokens === undefined && computedTotalTokens === undefined && reasoningTokens === undefined && cachedTokens === undefined)
43
+ return;
44
+ this.totals = {
45
+ inputTokens: this.totals.inputTokens + (inputTokens ?? 0),
46
+ outputTokens: this.totals.outputTokens + (outputTokens ?? 0),
47
+ totalTokens: this.totals.totalTokens + (computedTotalTokens ?? 0),
48
+ reasoningTokens: this.totals.reasoningTokens + (reasoningTokens ?? 0),
49
+ cachedTokens: this.totals.cachedTokens + (cachedTokens ?? 0),
50
+ requests: this.totals.requests + 1,
51
+ computedTotalTokens: this.totals.computedTotalTokens || (reportedTotalTokens === undefined && computedTotalTokens !== undefined),
52
+ };
53
+ }
54
+ reset() {
55
+ this.totals = emptyUsageTotals();
56
+ this.lastUsage = undefined;
57
+ }
58
+ snapshot() {
59
+ return { ...this.totals };
60
+ }
61
+ }
62
+ function emptyUsageTotals() {
63
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0, reasoningTokens: 0, cachedTokens: 0, requests: 0, computedTotalTokens: false };
64
+ }
65
+ function usageTokenValue(value) {
66
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
67
+ }
68
+ function sumUsageTokens(left, right) {
69
+ if (left === undefined && right === undefined)
70
+ return undefined;
71
+ return (left ?? 0) + (right ?? 0);
72
+ }
73
+ export async function runWebServer(argv = process.argv.slice(2)) {
74
+ const options = parseWebArgs(argv);
75
+ const runtime = await createRuntime();
76
+ const repl = new WebRepl(runtime);
77
+ const server = http.createServer((req, res) => void route(req, res, repl));
78
+ await new Promise((resolve) => server.listen(options.port, options.host, resolve));
79
+ const address = server.address();
80
+ const actualPort = typeof address === "object" && address ? address.port : options.port;
81
+ console.log(`neo web listening on http://${options.host === "0.0.0.0" ? "localhost" : options.host}:${actualPort}`);
82
+ }
83
+ function parseWebArgs(argv) {
84
+ let host = process.env.NEO_WEB_HOST || "127.0.0.1";
85
+ let port = Number(process.env.NEO_WEB_PORT || 3000);
86
+ for (let i = 0; i < argv.length; i += 1) {
87
+ const arg = argv[i];
88
+ if (arg === "--host" && argv[i + 1])
89
+ host = argv[++i];
90
+ else if (arg.startsWith("--host="))
91
+ host = arg.slice("--host=".length);
92
+ else if (arg === "--port" && argv[i + 1])
93
+ port = Number(argv[++i]);
94
+ else if (arg.startsWith("--port="))
95
+ port = Number(arg.slice("--port=".length));
96
+ }
97
+ if (!Number.isFinite(port) || port <= 0)
98
+ port = 3000;
99
+ return { host, port: Math.round(port) };
100
+ }
101
+ async function createRuntime() {
102
+ const envLoad = loadDefaultDotEnvFiles({ override: true });
103
+ const modelConfig = readModelProviderConfig(process.env);
104
+ const communicationLogger = new CommunicationLogger();
105
+ const modelGateway = new LoggingModelGateway(createModelGatewayFromProcessEnv(process.env), communicationLogger);
106
+ const taskStore = new TaskStore();
107
+ const tools = new ToolRegistry();
108
+ tools.register(editTool);
109
+ tools.register(writeTool);
110
+ tools.register(createExecTool({ taskStore }));
111
+ tools.register(listDirectoryTool);
112
+ tools.register(readFileTool);
113
+ tools.register(grepTool);
114
+ tools.register(searchTool);
115
+ tools.register(planTool);
116
+ const agentRuntime = { modelGateway, tools, taskStore };
117
+ tools.register(createAgentTool(agentRuntime));
118
+ const resumeHandler = async (taskId, directive) => {
119
+ const dummyContext = {
120
+ agentId: "main",
121
+ tools,
122
+ appState: new InMemoryAppState("main"),
123
+ emit: () => undefined,
124
+ };
125
+ return resumeAgentTask(taskId, directive, agentRuntime, taskStore, dummyContext);
126
+ };
127
+ for (const tool of createTaskTools(taskStore, resumeHandler))
128
+ tools.register(tool);
129
+ const engine = new QueryEngine({
130
+ agentId: "main",
131
+ model: modelConfig?.model,
132
+ fallbackModel: modelConfig?.fallbackModel,
133
+ reasoning: modelConfig?.defaultReasoning,
134
+ queryOrigin: "web",
135
+ modelGateway,
136
+ tools,
137
+ taskNotificationSource: createTaskNotificationSource(taskStore),
138
+ commands: replCommandDefinitions.map((command) => command.usage),
139
+ session: {
140
+ enabled: process.env.AGENT_SESSION_TRANSCRIPT !== "0",
141
+ sessionId: process.env.AGENT_SESSION_ID,
142
+ rootDir: process.env.AGENT_SESSION_DIR,
143
+ resume: parseResumeFlag(process.env.AGENT_SESSION_RESUME),
144
+ toolResultThresholdChars: process.env.AGENT_TOOL_RESULT_THRESHOLD_CHARS ? Number(process.env.AGENT_TOOL_RESULT_THRESHOLD_CHARS) : undefined,
145
+ },
146
+ });
147
+ await engine.initialize();
148
+ const initialMetrics = await engine.contextMetrics();
149
+ return {
150
+ engine,
151
+ communicationLogger,
152
+ modelGateway,
153
+ agentRuntime,
154
+ usage: new SessionUsageTracker(),
155
+ taskStore,
156
+ initialMetrics,
157
+ defaultReasoning: modelConfig?.defaultReasoning,
158
+ envPath: process.env.NEO_ENV_FILE?.trim() ? path.resolve(process.env.NEO_ENV_FILE.trim()) : envLoad.userDotEnvPath,
159
+ envNotice: envLoad.createdUserDotEnv ? formatCreatedEnvNotice(envLoad.userDotEnvPath) : undefined,
160
+ };
161
+ }
162
+ function createTaskNotificationSource(taskStore) {
163
+ return {
164
+ collectUnnotifiedCompletions() {
165
+ return taskStore.collectUnnotifiedCompletions().map((task) => ({
166
+ taskId: task.taskId,
167
+ agentId: task.agentId,
168
+ status: task.status,
169
+ type: task.type,
170
+ content: task.result?.content ?? task.error ?? "",
171
+ }));
172
+ },
173
+ markNotified(taskId) {
174
+ taskStore.markNotified(taskId);
175
+ },
176
+ };
177
+ }
178
+ function formatCreatedEnvNotice(dotEnvPath) {
179
+ return `Created default config file: ${dotEnvPath}\nSet MODEL_PROVIDER and the matching provider section (for example OPENAI_API_KEY or KIMI_API_KEY), then restart neo.`;
180
+ }
181
+ function parseResumeFlag(value) {
182
+ if (!value)
183
+ return false;
184
+ return ["1", "true", "yes", "latest"].includes(value.toLowerCase());
185
+ }
186
+ class WebRepl {
187
+ runtime;
188
+ subscribers = new Set();
189
+ lineId = 0;
190
+ assistantLineId;
191
+ thinkingLineId;
192
+ finalizedThinkingLineId;
193
+ activeAbortController;
194
+ interruptArmed = false;
195
+ toolLineIds = new Map();
196
+ lines;
197
+ status;
198
+ busy = false;
199
+ queuedInput;
200
+ foregroundRun;
201
+ backgroundSessionRuns = new Map();
202
+ suppressReattachedStreaming = new Set();
203
+ backgroundTaskCount;
204
+ constructor(runtime) {
205
+ this.runtime = runtime;
206
+ this.lines = initialLines(runtime, { current: 0 });
207
+ this.status = initialStatus(runtime);
208
+ this.backgroundTaskCount = runtime.taskStore.activeCount();
209
+ runtime.taskStore.subscribe(() => {
210
+ this.backgroundTaskCount = runtime.taskStore.activeCount();
211
+ this.broadcastSync();
212
+ });
213
+ runtime.engine.onSessionTitleChange(() => this.broadcastSync());
214
+ }
215
+ subscribe(res) {
216
+ this.subscribers.add(res);
217
+ res.writeHead(200, {
218
+ "Content-Type": "text/event-stream; charset=utf-8",
219
+ "Cache-Control": "no-cache, no-transform",
220
+ Connection: "keep-alive",
221
+ "X-Accel-Buffering": "no",
222
+ });
223
+ this.send(res, "sync", this.snapshot(true));
224
+ reqKeepAlive(res);
225
+ res.on("close", () => this.subscribers.delete(res));
226
+ }
227
+ snapshot(includeCatalog = false) {
228
+ return {
229
+ lines: this.lines,
230
+ status: this.status,
231
+ busy: this.busy,
232
+ queuedInput: this.queuedInput,
233
+ backgroundTaskCount: this.backgroundTaskCount,
234
+ backgroundTasks: this.backgroundTasks(),
235
+ backgroundSessionRunCount: this.backgroundSessionRuns.size,
236
+ runningSessionIds: [...this.backgroundSessionRuns.keys()],
237
+ session: this.runtime.engine.snapshot().session,
238
+ catalog: includeCatalog ? webCatalog(this.runtime) : undefined,
239
+ interactive: includeCatalog ? webInteractiveCatalog(this.runtime) : undefined,
240
+ tips: includeCatalog ? appTips : undefined,
241
+ tipIndex: initialTipIndex(this.runtime.engine.snapshot().session?.sessionId ?? process.cwd()),
242
+ };
243
+ }
244
+ async submit(text, attachments = []) {
245
+ const trimmed = text.trim();
246
+ if (!trimmed && attachments.length === 0)
247
+ return { ok: true };
248
+ const command = parseReplCommand(text);
249
+ const startsNewSessionWhileBusy = this.busy && command.type === "new";
250
+ if (this.busy && !startsNewSessionWhileBusy) {
251
+ if (this.queuedInput !== undefined)
252
+ return { ok: false, error: "A queued prompt is already waiting. Press Esc/Ctrl+C in the web UI to clear it." };
253
+ this.queuedInput = text;
254
+ this.broadcastSync();
255
+ return { ok: true };
256
+ }
257
+ const run = this.handleCommandOrPrompt(text, attachments).catch((error) => {
258
+ this.append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
259
+ this.setBusy(false);
260
+ this.setStatus({ ...this.status, phase: "ready", detail: undefined });
261
+ });
262
+ this.foregroundRun = run;
263
+ run.finally(() => {
264
+ if (this.foregroundRun === run)
265
+ this.foregroundRun = undefined;
266
+ }).catch(() => undefined);
267
+ return { ok: true };
268
+ }
269
+ async listSessions() {
270
+ const sessions = await this.runtime.engine.listSessions(Number.POSITIVE_INFINITY);
271
+ const runningSessionIds = [...this.backgroundSessionRuns.keys()];
272
+ return { sessions, runningSessionIds };
273
+ }
274
+ async resumeSession(sessionId) {
275
+ if (!sessionId)
276
+ return { ok: false, error: "sessionId is required" };
277
+ try {
278
+ const running = this.backgroundSessionRuns.get(sessionId);
279
+ if (running) {
280
+ await this.reattachRunningSession(running);
281
+ return { ok: true };
282
+ }
283
+ await this.detachRunningForeground("session switch");
284
+ this.runtime.engine = this.runtime.engine.forkForSession(sessionId, true);
285
+ await this.runtime.engine.initialize();
286
+ const snapshot = this.runtime.engine.snapshot().session;
287
+ if (!snapshot)
288
+ throw new Error("session transcripts are disabled");
289
+ await this.refreshSessionView(systemLine(formatResume(snapshot)));
290
+ return { ok: true };
291
+ }
292
+ catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ this.append({ kind: "error", text: message });
295
+ return { ok: false, error: message };
296
+ }
297
+ }
298
+ async newSession() {
299
+ try {
300
+ await this.detachRunningForeground("new session");
301
+ this.runtime.engine = this.runtime.engine.forkForSession(undefined, false);
302
+ await this.runtime.engine.initialize();
303
+ const snapshot = this.runtime.engine.snapshot().session;
304
+ if (!snapshot)
305
+ throw new Error("session transcripts are disabled");
306
+ await this.refreshSessionView(systemLine(`new session ${snapshot.sessionId}`));
307
+ return { ok: true };
308
+ }
309
+ catch (error) {
310
+ const message = error instanceof Error ? error.message : String(error);
311
+ this.append({ kind: "error", text: message });
312
+ return { ok: false, error: message };
313
+ }
314
+ }
315
+ async refreshSessionView(line) {
316
+ const metrics = await this.runtime.engine.contextMetrics();
317
+ this.runtime.usage.reset();
318
+ this.status = initialStatus(this.runtime, metrics);
319
+ const lineId = { current: 0 };
320
+ this.lines = initialLines(this.runtime, lineId);
321
+ this.lineId = lineId.current;
322
+ this.assistantLineId = undefined;
323
+ this.thinkingLineId = undefined;
324
+ this.finalizedThinkingLineId = undefined;
325
+ this.toolLineIds.clear();
326
+ if (line)
327
+ this.append(line);
328
+ this.broadcastSync();
329
+ }
330
+ async deleteSession(sessionId) {
331
+ if (!sessionId)
332
+ return { ok: false, error: "sessionId is required" };
333
+ try {
334
+ const deleted = await this.runtime.engine.deleteSession(sessionId);
335
+ if (!deleted)
336
+ return { ok: false, error: `session not found: ${sessionId}` };
337
+ this.append(systemLine(`deleted session ${sessionId}`));
338
+ return { ok: true };
339
+ }
340
+ catch (error) {
341
+ const message = error instanceof Error ? error.message : String(error);
342
+ this.append({ kind: "error", text: message });
343
+ return { ok: false, error: message };
344
+ }
345
+ }
346
+ loginForm(providerValue) {
347
+ const provider = parseLoginProvider(providerValue);
348
+ return createLoginFormPayload(this.runtime.envPath, provider);
349
+ }
350
+ async saveLogin(providerValue, values) {
351
+ const provider = parseLoginProvider(providerValue);
352
+ if (!provider)
353
+ return { ok: false, error: "provider must be openai, deepseek, or kimi" };
354
+ const payload = { ...createLoginFormPayload(this.runtime.envPath, provider), provider, values };
355
+ const validationError = validateLoginFormPayload(payload);
356
+ if (validationError)
357
+ return { ok: false, error: validationError };
358
+ try {
359
+ await saveLoginPayloadToEnv(payload);
360
+ applyLoginPayloadToProcessEnv(payload);
361
+ const config = readModelProviderConfig(process.env);
362
+ if (!config)
363
+ throw new Error("Saved provider config could not be loaded from environment.");
364
+ const innerGateway = createModelGatewayFromConfig(config);
365
+ this.runtime.modelGateway.setInner(innerGateway);
366
+ this.runtime.agentRuntime.modelGateway = this.runtime.modelGateway;
367
+ this.runtime.engine.setModelProvider({ modelGateway: this.runtime.modelGateway, model: config.model, fallbackModel: config.fallbackModel, reasoning: config.defaultReasoning });
368
+ this.runtime.defaultReasoning = config.defaultReasoning;
369
+ this.status = { ...this.status, metrics: { ...initialContextMetrics(config.model, this.runtime.engine.snapshot().messages, this.runtime.initialMetrics.toolCount), messageCount: this.runtime.engine.snapshot().messages } };
370
+ this.append(systemLine(`Saved ${provider} login to ${this.runtime.envPath}\n${formatModelSettings(this.runtime.engine.getModelSettings(), this.runtime.defaultReasoning)}`, EXPANDED_SUMMARY_MAX_LINES));
371
+ return { ok: true };
372
+ }
373
+ catch (error) {
374
+ const message = `Login save failed: ${error instanceof Error ? error.message : String(error)}`;
375
+ this.append({ kind: "error", text: message });
376
+ return { ok: false, error: message };
377
+ }
378
+ }
379
+ interrupt() {
380
+ if (this.queuedInput !== undefined) {
381
+ this.queuedInput = undefined;
382
+ this.broadcastSync();
383
+ return { ok: true, interrupted: false };
384
+ }
385
+ const controller = this.activeAbortController;
386
+ if (controller && !controller.signal.aborted && !this.interruptArmed) {
387
+ this.interruptArmed = true;
388
+ controller.abort("Interrupted from neo web");
389
+ this.setStatus({ ...this.status, phase: "stopped", detail: "interrupt requested" });
390
+ return { ok: true, interrupted: true };
391
+ }
392
+ return { ok: true, interrupted: false };
393
+ }
394
+ append(line) {
395
+ const id = ++this.lineId;
396
+ this.lines.push({ id, ...line });
397
+ this.broadcastSync();
398
+ return id;
399
+ }
400
+ updateLine(id, updater) {
401
+ this.lines = this.lines.map((line) => line.id === id ? { ...line, text: updater(line.text) } : line);
402
+ this.broadcastSync();
403
+ }
404
+ replaceLineText(id, text) {
405
+ this.lines = this.lines.map((line) => line.id === id ? { ...line, text } : line);
406
+ this.broadcastSync();
407
+ }
408
+ replaceLine(id, patch) {
409
+ this.lines = this.lines.map((line) => line.id === id ? { ...line, ...patch } : line);
410
+ this.broadcastSync();
411
+ }
412
+ setBusy(next) {
413
+ this.busy = next;
414
+ this.broadcastSync();
415
+ }
416
+ setStatus(next) {
417
+ this.status = next;
418
+ this.broadcastSync();
419
+ }
420
+ backgroundTasks() {
421
+ return this.runtime.taskStore.list()
422
+ .filter((task) => !this.runtime.taskStore.isTerminal(task))
423
+ .map((task) => ({
424
+ taskId: task.taskId,
425
+ agentId: task.agentId,
426
+ type: task.type,
427
+ status: task.status,
428
+ description: task.description,
429
+ createdAt: task.createdAt,
430
+ }));
431
+ }
432
+ async detachRunningForeground(reason) {
433
+ if (!this.busy)
434
+ return false;
435
+ const snapshot = this.runtime.engine.snapshot().session;
436
+ const sessionId = snapshot?.sessionId ?? `session-${Date.now().toString(36)}`;
437
+ const run = this.foregroundRun;
438
+ if (run && !this.backgroundSessionRuns.has(sessionId)) {
439
+ const backgroundRun = {
440
+ sessionId,
441
+ title: snapshot?.title,
442
+ reason,
443
+ startedAt: Date.now(),
444
+ engine: this.runtime.engine,
445
+ abortController: this.activeAbortController ?? new AbortController(),
446
+ promise: run,
447
+ };
448
+ this.backgroundSessionRuns.set(sessionId, backgroundRun);
449
+ run.finally(() => {
450
+ this.backgroundSessionRuns.delete(sessionId);
451
+ this.suppressReattachedStreaming.delete(backgroundRun.engine);
452
+ this.broadcastSync();
453
+ }).catch(() => undefined);
454
+ }
455
+ this.activeAbortController = undefined;
456
+ this.interruptArmed = false;
457
+ this.queuedInput = undefined;
458
+ this.busy = false;
459
+ this.status = { ...this.status, phase: "ready", detail: undefined };
460
+ this.append(systemLine(`Detached running ${sessionId} to background for ${reason}.`));
461
+ return true;
462
+ }
463
+ async reattachRunningSession(run) {
464
+ await this.detachRunningForeground("session switch");
465
+ this.backgroundSessionRuns.delete(run.sessionId);
466
+ this.runtime.engine = run.engine;
467
+ this.activeAbortController = run.abortController;
468
+ this.interruptArmed = false;
469
+ this.foregroundRun = run.promise;
470
+ this.suppressReattachedStreaming.add(run.engine);
471
+ await this.refreshSessionView(systemLine(`reattached running session ${run.sessionId}`));
472
+ this.setBusy(true);
473
+ this.setStatus({ ...this.status, phase: "running", detail: "working" });
474
+ }
475
+ reduce(event) {
476
+ this.status = reduceStatus(this.status, event);
477
+ if (event.type === "usage")
478
+ this.runtime.usage.add(event.usage);
479
+ }
480
+ finalizeLiveLine(id) {
481
+ if (id === undefined)
482
+ return;
483
+ this.lines = this.lines.map((line) => line.id === id ? { ...line, live: false } : line);
484
+ }
485
+ finalizeThinkingLine() {
486
+ const id = this.thinkingLineId;
487
+ if (id === undefined)
488
+ return;
489
+ this.finalizeLiveLine(id);
490
+ this.finalizedThinkingLineId = id;
491
+ this.thinkingLineId = undefined;
492
+ }
493
+ finalizeActiveToolLines() {
494
+ for (const id of this.toolLineIds.values())
495
+ this.replaceLine(id, { live: false, pendingReplacement: false });
496
+ this.toolLineIds.clear();
497
+ }
498
+ handleEvent(event) {
499
+ this.reduce(event);
500
+ if (event.type === "state" || event.type === "context.metrics" || event.type === "usage" || event.type === "tool_call.delta" || event.type === "retrying") {
501
+ this.broadcastSync();
502
+ return;
503
+ }
504
+ if (event.type === "assistant.delta") {
505
+ this.finalizeThinkingLine();
506
+ const id = this.assistantLineId ?? this.append({ kind: "assistant", text: "", live: true });
507
+ this.assistantLineId = id;
508
+ this.updateLine(id, (text) => text + event.text);
509
+ return;
510
+ }
511
+ if (event.type === "thinking.delta") {
512
+ const id = this.thinkingLineId ?? this.finalizedThinkingLineId ?? this.append(thinkingLine("", true));
513
+ this.thinkingLineId = id;
514
+ this.finalizedThinkingLineId = undefined;
515
+ this.updateLine(id, (text) => text + event.text);
516
+ return;
517
+ }
518
+ if (event.type === "message") {
519
+ let replacedStreamingContent = false;
520
+ if (event.message.role === "assistant" && this.assistantLineId !== undefined) {
521
+ const text = assistantText(event.message);
522
+ if (text !== undefined) {
523
+ this.replaceLineText(this.assistantLineId, text);
524
+ this.finalizeLiveLine(this.assistantLineId);
525
+ this.assistantLineId = undefined;
526
+ replacedStreamingContent = true;
527
+ }
528
+ }
529
+ const thinkingId = this.thinkingLineId ?? this.finalizedThinkingLineId;
530
+ if (event.message.role === "assistant" && thinkingId !== undefined) {
531
+ const text = thinkingText(event.message);
532
+ if (text !== undefined) {
533
+ this.replaceLineText(thinkingId, text);
534
+ this.finalizeLiveLine(thinkingId);
535
+ this.thinkingLineId = undefined;
536
+ this.finalizedThinkingLineId = undefined;
537
+ replacedStreamingContent = true;
538
+ }
539
+ }
540
+ if (replacedStreamingContent) {
541
+ this.broadcastSync();
542
+ return;
543
+ }
544
+ if (event.message.role === "tool_result") {
545
+ renderToolResultMessage(event.message, (line) => this.append(line), (id, patch) => this.replaceLine(id, patch), this.toolLineIds);
546
+ return;
547
+ }
548
+ if (event.message.role !== "assistant") {
549
+ this.finalizeLiveLine(this.assistantLineId);
550
+ this.finalizeThinkingLine();
551
+ this.assistantLineId = undefined;
552
+ }
553
+ const rendered = renderMessage(event.message, (line) => this.append(line), this.assistantLineId);
554
+ if (rendered && event.message.role === "assistant") {
555
+ this.finalizeLiveLine(this.assistantLineId);
556
+ this.finalizeThinkingLine();
557
+ this.assistantLineId = undefined;
558
+ }
559
+ this.broadcastSync();
560
+ return;
561
+ }
562
+ if (event.type === "tool.started") {
563
+ this.finalizeLiveLine(this.assistantLineId);
564
+ this.finalizeThinkingLine();
565
+ const id = this.append({ ...formatToolUse(event.toolUse), live: true });
566
+ this.toolLineIds.set(event.toolUse.id, id);
567
+ return;
568
+ }
569
+ if (event.type === "tool.finished") {
570
+ const id = this.toolLineIds.get(event.toolUse.id);
571
+ if (id !== undefined) {
572
+ this.replaceLine(id, formatToolFinishedWithoutResult(event.toolUse, event.ok));
573
+ this.toolLineIds.delete(event.toolUse.id);
574
+ }
575
+ return;
576
+ }
577
+ if (event.type === "terminal") {
578
+ this.finalizeLiveLine(this.assistantLineId);
579
+ this.finalizeThinkingLine();
580
+ this.finalizeActiveToolLines();
581
+ this.assistantLineId = undefined;
582
+ this.broadcastSync();
583
+ return;
584
+ }
585
+ if (event.type === "error")
586
+ this.append({ kind: "error", text: event.error.message });
587
+ }
588
+ async handleCommandOrPrompt(text, attachments = []) {
589
+ const command = parseReplCommand(text);
590
+ if (command.type === "exit") {
591
+ this.append(systemLine("neo web server is still running. Close this tab or stop the server process with Ctrl+C."));
592
+ return;
593
+ }
594
+ if (command.type === "help")
595
+ return void this.append(systemLine(helpText, EXPANDED_SUMMARY_MAX_LINES));
596
+ if (command.type === "cost")
597
+ return void this.append({ kind: "system", text: formatUsageTotals(this.runtime.usage.snapshot()), previewStyle: "summary" });
598
+ if (command.type === "reset") {
599
+ this.runtime.engine.reset();
600
+ this.runtime.usage.reset();
601
+ this.status = await resetStatus(this.runtime);
602
+ this.append(systemLine("transcript reset"));
603
+ return;
604
+ }
605
+ if (command.type === "state") {
606
+ const contextMetrics = await this.runtime.engine.contextMetrics();
607
+ this.append(systemLine(formatReplData({ ...this.runtime.engine.snapshot(), contextMetrics, communicationLog: this.runtime.communicationLogger.snapshot() }, 12000), EXPANDED_SUMMARY_MAX_LINES));
608
+ return;
609
+ }
610
+ if (command.type === "new") {
611
+ await this.newSession();
612
+ return;
613
+ }
614
+ if (command.type === "sessions") {
615
+ const sessions = await this.runtime.engine.listSessions(30);
616
+ void sessions;
617
+ if (sessions.length === 0)
618
+ this.append(systemLine("No saved sessions found."));
619
+ this.broadcastSync();
620
+ return;
621
+ }
622
+ if (command.type === "export") {
623
+ this.setBusy(true);
624
+ this.setStatus({ ...this.status, phase: "running", detail: "exporting session", activityTick: this.status.activityTick + 1 });
625
+ try {
626
+ this.append(await handleExportCommand(command.path, this.runtime));
627
+ }
628
+ finally {
629
+ this.setBusy(false);
630
+ this.setStatus({ ...this.status, phase: "ready", detail: undefined, activityTick: this.status.activityTick + 1 });
631
+ }
632
+ return;
633
+ }
634
+ if (command.type === "env") {
635
+ const envDirectory = path.dirname(this.runtime.envPath);
636
+ try {
637
+ await fs.mkdir(envDirectory, { recursive: true });
638
+ await openDirectory(envDirectory);
639
+ this.append({ kind: "system", title: "System", text: `Opened env directory: ${envDirectory}`, format: "plain", previewStyle: "summary" });
640
+ }
641
+ catch (error) {
642
+ this.append({ kind: "error", text: `Failed to open env directory ${envDirectory}: ${error instanceof Error ? error.message : String(error)}`, format: "plain" });
643
+ }
644
+ return;
645
+ }
646
+ if (command.type === "log") {
647
+ await handleLogCommand(command, this.runtime, (line) => this.append(line));
648
+ return;
649
+ }
650
+ if (command.type === "model") {
651
+ this.setBusy(true);
652
+ this.setStatus({ ...this.status, phase: "running", detail: "saving model settings", activityTick: this.status.activityTick + 1 });
653
+ try {
654
+ this.append(await handleModelCommand(command, this.runtime));
655
+ const metrics = await this.runtime.engine.contextMetrics();
656
+ this.setStatus({ ...this.status, phase: "ready", detail: undefined, metrics, activityTick: this.status.activityTick + 1 });
657
+ }
658
+ finally {
659
+ this.setBusy(false);
660
+ }
661
+ return;
662
+ }
663
+ if (command.type === "compact" || command.type === "pure") {
664
+ await this.runCompaction(command.type);
665
+ return;
666
+ }
667
+ if (command.type === "login") {
668
+ this.broadcastSync();
669
+ return;
670
+ }
671
+ if (text.trimStart().startsWith("/")) {
672
+ this.append({ kind: "error", text: `Unknown or incomplete command: ${text.trim()}\nType /help for commands.` });
673
+ return;
674
+ }
675
+ const promptPayload = buildWebPromptPayload(command.text, attachments);
676
+ if (promptPayload.blocks?.some((block) => block.type === "image") && !this.runtime.engine.canAcceptImageInput()) {
677
+ this.append({ kind: "error", text: "Current model does not support image input; image attachments were not added to the conversation." });
678
+ return;
679
+ }
680
+ this.append({ kind: "user", text: promptPayload.displayText });
681
+ const abortController = new AbortController();
682
+ this.activeAbortController = abortController;
683
+ this.interruptArmed = false;
684
+ this.setBusy(true);
685
+ this.setStatus({ ...this.status, phase: "running", detail: "working", usage: undefined, streamedOutputTokens: 0, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined });
686
+ const engine = this.runtime.engine;
687
+ try {
688
+ for await (const event of engine.sendUserText(promptPayload.text, { abortSignal: abortController.signal, blocks: promptPayload.blocks, displayText: promptPayload.displayText })) {
689
+ if (this.runtime.engine !== engine)
690
+ continue;
691
+ if (this.suppressReattachedStreaming.has(engine)) {
692
+ if (event.type === "message" || event.type === "terminal" || event.type === "error" || event.type === "context.metrics" || event.type === "usage") {
693
+ if (event.type === "message" || event.type === "terminal" || event.type === "error")
694
+ this.suppressReattachedStreaming.delete(engine);
695
+ this.handleEvent(event);
696
+ }
697
+ continue;
698
+ }
699
+ this.handleEvent(event);
700
+ }
701
+ }
702
+ catch (error) {
703
+ if (this.runtime.engine === engine) {
704
+ this.finalizeLiveLine(this.assistantLineId);
705
+ this.finalizeThinkingLine();
706
+ this.finalizeActiveToolLines();
707
+ this.assistantLineId = undefined;
708
+ this.finalizedThinkingLineId = undefined;
709
+ this.append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
710
+ }
711
+ }
712
+ finally {
713
+ if (this.runtime.engine !== engine)
714
+ return;
715
+ if (this.activeAbortController === abortController)
716
+ this.activeAbortController = undefined;
717
+ this.interruptArmed = false;
718
+ this.finalizeLiveLine(this.assistantLineId);
719
+ this.finalizeThinkingLine();
720
+ this.finalizeActiveToolLines();
721
+ this.assistantLineId = undefined;
722
+ this.finalizedThinkingLineId = undefined;
723
+ this.setBusy(false);
724
+ this.setStatus({ ...this.status, phase: "ready", detail: undefined, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined });
725
+ const queued = this.queuedInput;
726
+ this.queuedInput = undefined;
727
+ if (queued !== undefined)
728
+ void this.handleCommandOrPrompt(queued);
729
+ this.broadcastSync();
730
+ }
731
+ }
732
+ async runCompaction(type) {
733
+ const abortController = new AbortController();
734
+ this.activeAbortController = abortController;
735
+ this.interruptArmed = false;
736
+ this.setBusy(true);
737
+ this.setStatus({ ...this.status, phase: "compacting", detail: type === "compact" ? "manual compact" : "pure compact", activityTick: this.status.activityTick + 1 });
738
+ try {
739
+ const result = type === "compact"
740
+ ? await this.runtime.engine.compact({ abortSignal: abortController.signal })
741
+ : await this.runtime.engine.pureCompact({ abortSignal: abortController.signal });
742
+ const metrics = await this.runtime.engine.contextMetrics();
743
+ this.append(systemLine(type === "compact" ? formatManualCompaction(result) : formatPureCompaction(result)));
744
+ this.handleEvent({ type: "context.metrics", metrics });
745
+ }
746
+ catch (error) {
747
+ this.append({ kind: "error", text: error instanceof Error ? error.message : String(error) });
748
+ }
749
+ finally {
750
+ if (this.activeAbortController === abortController)
751
+ this.activeAbortController = undefined;
752
+ this.interruptArmed = false;
753
+ this.setBusy(false);
754
+ this.setStatus({ ...this.status, phase: "ready", detail: undefined, activityTick: this.status.activityTick + 1 });
755
+ const queued = this.queuedInput;
756
+ this.queuedInput = undefined;
757
+ if (queued !== undefined)
758
+ void this.handleCommandOrPrompt(queued);
759
+ }
760
+ }
761
+ send(res, event, data) {
762
+ res.write(`event: ${event}\n`);
763
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
764
+ }
765
+ broadcastSync() {
766
+ const payload = this.snapshot(false);
767
+ for (const res of this.subscribers)
768
+ this.send(res, "sync", payload);
769
+ }
770
+ }
771
+ function reqKeepAlive(res) {
772
+ const timer = setInterval(() => res.write(": keep-alive\n\n"), 25_000);
773
+ timer.unref?.();
774
+ res.on("close", () => clearInterval(timer));
775
+ }
776
+ async function route(req, res, repl) {
777
+ const url = new URL(req.url ?? "/", "http://localhost");
778
+ try {
779
+ if (req.method === "GET" && url.pathname === "/")
780
+ return sendHtml(res, WEB_HTML);
781
+ if (req.method === "GET" && url.pathname === "/vendor/marked.esm.js")
782
+ return sendFile(res, path.join(process.cwd(), "node_modules", "marked", "lib", "marked.esm.js"), "text/javascript; charset=utf-8");
783
+ if (req.method === "GET" && url.pathname === "/vendor/highlight.min.js")
784
+ return sendFile(res, path.join(process.cwd(), "node_modules", "@highlightjs", "cdn-assets", "highlight.min.js"), "text/javascript; charset=utf-8");
785
+ if (req.method === "GET" && url.pathname === "/vendor/highlight-theme.css")
786
+ return sendFile(res, path.join(process.cwd(), "node_modules", "@highlightjs", "cdn-assets", "styles", "atom-one-dark.min.css"), "text/css; charset=utf-8");
787
+ if (req.method === "GET" && url.pathname === "/events")
788
+ return repl.subscribe(res);
789
+ if (req.method === "GET" && url.pathname === "/api/state")
790
+ return sendJson(res, repl.snapshot(true));
791
+ if (req.method === "POST" && url.pathname === "/api/submit") {
792
+ const body = await readJsonBody(req);
793
+ return sendJson(res, await repl.submit(String(body.text ?? ""), sanitizeWebAttachments(body.attachments)));
794
+ }
795
+ if (req.method === "POST" && url.pathname === "/api/interrupt")
796
+ return sendJson(res, repl.interrupt());
797
+ if (req.method === "GET" && url.pathname === "/api/sessions")
798
+ return sendJson(res, await repl.listSessions());
799
+ if (req.method === "POST" && url.pathname === "/api/sessions/resume") {
800
+ const body = await readJsonBody(req);
801
+ return sendJson(res, await repl.resumeSession(String(body.sessionId ?? "")));
802
+ }
803
+ if (req.method === "POST" && url.pathname === "/api/sessions/new")
804
+ return sendJson(res, await repl.newSession());
805
+ if (req.method === "POST" && url.pathname === "/api/sessions/delete") {
806
+ const body = await readJsonBody(req);
807
+ return sendJson(res, await repl.deleteSession(String(body.sessionId ?? "")));
808
+ }
809
+ if (req.method === "GET" && url.pathname === "/api/login")
810
+ return sendJson(res, repl.loginForm(url.searchParams.get("provider") ?? undefined));
811
+ if (req.method === "POST" && url.pathname === "/api/login") {
812
+ const body = await readJsonBody(req);
813
+ return sendJson(res, await repl.saveLogin(String(body.provider ?? ""), body.values ?? {}));
814
+ }
815
+ sendJson(res, { error: "not found" }, 404);
816
+ }
817
+ catch (error) {
818
+ sendJson(res, { error: error instanceof Error ? error.message : String(error) }, 500);
819
+ }
820
+ }
821
+ function sendHtml(res, body) {
822
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
823
+ res.end(body);
824
+ }
825
+ async function sendFile(res, filepath, contentType) {
826
+ const body = await fs.readFile(filepath);
827
+ res.writeHead(200, { "Content-Type": contentType, "Cache-Control": "public, max-age=3600" });
828
+ res.end(body);
829
+ }
830
+ function sendJson(res, value, status = 200) {
831
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
832
+ res.end(JSON.stringify(value));
833
+ }
834
+ async function readJsonBody(req) {
835
+ const chunks = [];
836
+ for await (const chunk of req)
837
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
838
+ if (chunks.length === 0)
839
+ return {};
840
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
841
+ }
842
+ function sanitizeWebAttachments(value) {
843
+ if (!Array.isArray(value))
844
+ return [];
845
+ return value.filter((item) => {
846
+ if (!isRecord(item))
847
+ return false;
848
+ return item.kind === "image" && typeof item.label === "string" && /^\[img#\d+\]$/.test(item.label) && typeof item.mimeType === "string" && item.mimeType.startsWith("image/") && typeof item.data === "string";
849
+ });
850
+ }
851
+ function webCatalog(runtime) {
852
+ const modelIds = [...new Set(loadModelCatalog().models.flatMap((model) => model.modelIds.length ? model.modelIds : [model.id]))].sort((left, right) => left.localeCompare(right));
853
+ return {
854
+ commands: replCommandDefinitions,
855
+ modelIds,
856
+ reasoning: ["none", "minimal", "low", "medium", "high", "xhigh", "max", "default", "off"],
857
+ envPath: runtime.envPath,
858
+ };
859
+ }
860
+ function webInteractiveCatalog(runtime) {
861
+ return {
862
+ sessions: true,
863
+ login: createLoginFormPayload(runtime.envPath),
864
+ };
865
+ }
866
+ function initialLines(runtime, lineId) {
867
+ const session = runtime.engine.snapshot().session;
868
+ const suffix = session ? ` Session: ${session.sessionId}${session.resumedMessages > 0 ? ` (${session.resumedMessages} resumed messages)` : ""}.` : "";
869
+ const lines = [
870
+ { id: 0, kind: "system", title: "System", text: `Interactive web UI enabled. Type /help for commands.${suffix}\n${formatTipLine(tipAt(initialTipIndex(session?.sessionId ?? process.cwd())))}`, previewStyle: "summary" },
871
+ ];
872
+ lineId.current = 0;
873
+ if (runtime.envNotice)
874
+ lines.push({ id: ++lineId.current, kind: "system", title: "Config", text: runtime.envNotice, format: "plain", previewStyle: "summary" });
875
+ for (const line of restoredHistoryLines(runtime))
876
+ lines.push({ id: ++lineId.current, ...line });
877
+ return lines;
878
+ }
879
+ function restoredHistoryLines(runtime) {
880
+ const lines = [];
881
+ const append = (line) => {
882
+ lines.push(line);
883
+ return lines.length;
884
+ };
885
+ for (const message of runtime.engine.getHistoryMessages())
886
+ renderMessage(message, append, undefined, { includeToolUseBlocks: true });
887
+ return lines;
888
+ }
889
+ function initialStatus(runtime, metrics = runtime.initialMetrics) {
890
+ return { phase: "ready", metrics: { ...metrics, messageCount: runtime.engine.snapshot().messages }, streamedOutputTokens: 0, activityTick: 0 };
891
+ }
892
+ async function resetStatus(runtime) {
893
+ return initialStatus(runtime, await runtime.engine.contextMetrics());
894
+ }
895
+ function buildWebPromptPayload(displayText, attachments) {
896
+ const activeAttachments = attachments.filter((attachment) => displayText.includes(attachment.label));
897
+ if (activeAttachments.length === 0)
898
+ return { text: displayText, displayText };
899
+ const blocks = [];
900
+ let cursor = 0;
901
+ while (cursor < displayText.length) {
902
+ const next = nextWebAttachmentOccurrence(displayText, activeAttachments, cursor);
903
+ if (!next) {
904
+ pushTextBlock(blocks, displayText.slice(cursor));
905
+ break;
906
+ }
907
+ pushTextBlock(blocks, displayText.slice(cursor, next.index));
908
+ blocks.push({ type: "image", mimeType: next.attachment.mimeType, data: next.attachment.data, label: next.attachment.label });
909
+ cursor = next.index + next.attachment.label.length;
910
+ }
911
+ const text = blocks.map((block) => block.type === "text" ? block.text : block.type === "image" ? block.label ?? "[image]" : "").join("");
912
+ return { text, displayText: text, blocks };
913
+ }
914
+ function nextWebAttachmentOccurrence(text, attachments, start) {
915
+ let best;
916
+ for (const attachment of attachments) {
917
+ const index = text.indexOf(attachment.label, start);
918
+ if (index === -1)
919
+ continue;
920
+ if (!best || index < best.index)
921
+ best = { index, attachment };
922
+ }
923
+ return best;
924
+ }
925
+ function pushTextBlock(blocks, text) {
926
+ if (!text)
927
+ return;
928
+ const previous = blocks[blocks.length - 1];
929
+ if (previous?.type === "text")
930
+ previous.text += text;
931
+ else
932
+ blocks.push({ type: "text", text });
933
+ }
934
+ function initialContextMetrics(model, messageCount, toolCount) {
935
+ const window = resolveContextWindowTokens(model);
936
+ return {
937
+ model,
938
+ estimatedInputTokens: 0,
939
+ estimatedChars: 0,
940
+ messageCount,
941
+ toolCount,
942
+ contextWindowTokens: window.tokens,
943
+ contextWindowSource: window.source,
944
+ contextUsageRatio: window.tokens ? 0 : undefined,
945
+ modelMetadata: window.model ? {
946
+ id: window.model.id,
947
+ provider: window.model.provider,
948
+ maxOutputTokens: window.model.maxOutputTokens,
949
+ knowledgeCutoff: window.model.knowledgeCutoff,
950
+ reasoning: window.model.reasoning,
951
+ imageInput: window.model.imageInput,
952
+ source: window.model.source,
953
+ } : undefined,
954
+ };
955
+ }
956
+ function reduceStatus(status, event) {
957
+ if (event.type === "state")
958
+ return { ...status, phase: event.phase, detail: event.detail, usage: event.phase === "preparing" ? undefined : status.usage, streamedOutputTokens: event.phase === "preparing" ? 0 : status.streamedOutputTokens, inputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.phase === "preparing" ? undefined : status.outputTokenUpdatedAt, retryCooldownUntil: event.phase === "preparing" ? undefined : status.retryCooldownUntil, activityTick: status.activityTick + 1 };
959
+ if (event.type === "context.metrics")
960
+ return { ...status, metrics: event.metrics, inputTokenUpdatedAt: event.metrics.estimatedInputTokens !== status.metrics?.estimatedInputTokens ? Date.now() : status.inputTokenUpdatedAt, activityTick: status.activityTick + 1 };
961
+ if (event.type === "usage")
962
+ return { ...status, usage: event.usage, inputTokenUpdatedAt: event.usage.inputTokens !== undefined ? Date.now() : status.inputTokenUpdatedAt, outputTokenUpdatedAt: event.usage.outputTokens !== undefined ? Date.now() : status.outputTokenUpdatedAt, activityTick: status.activityTick + 1 };
963
+ if (event.type === "assistant.delta")
964
+ return { ...status, phase: "calling_model", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
965
+ if (event.type === "thinking.delta")
966
+ return { ...status, phase: "thinking", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.text), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
967
+ if (event.type === "tool_call.delta")
968
+ return { ...status, phase: "calling_model", streamedOutputTokens: status.streamedOutputTokens + estimateTokens(event.argumentsDelta), outputTokenUpdatedAt: Date.now(), activityTick: status.activityTick + 1 };
969
+ if (event.type === "retrying")
970
+ return { ...status, phase: "calling_model", detail: `retrying in ${(event.delayMs / 1000).toFixed(1)}s`, retryCooldownUntil: Date.now() + event.delayMs, activityTick: status.activityTick + 1 };
971
+ if (event.type === "terminal")
972
+ return { ...status, phase: "stopped", detail: event.reason, inputTokenUpdatedAt: undefined, outputTokenUpdatedAt: undefined, retryCooldownUntil: undefined, activityTick: status.activityTick + 1 };
973
+ if (event.type === "message" || event.type === "tool.started" || event.type === "tool.finished" || event.type === "error")
974
+ return { ...status, activityTick: status.activityTick + 1 };
975
+ return status;
976
+ }
977
+ async function handleExportCommand(outputPath, runtime) {
978
+ const snapshot = runtime.engine.snapshot();
979
+ if (!snapshot.session)
980
+ throw new Error("session transcripts are disabled; cannot export current session");
981
+ const promptSnapshot = await runtime.engine.promptExportSnapshot();
982
+ const result = await writeSessionMarkdownExport({
983
+ outputPath,
984
+ session: snapshot.session,
985
+ agentId: snapshot.agentId,
986
+ promptSnapshot,
987
+ engineSnapshot: { ...snapshot, communicationLog: runtime.communicationLogger.snapshot(), usage: runtime.usage.snapshot() },
988
+ });
989
+ return systemLine(`Exported current session to ${result.outputPath}\nEntries: ${result.entries}\nMessages: ${result.messages}\nBytes: ${result.bytes}`);
990
+ }
991
+ async function handleLogCommand(command, runtime, append) {
992
+ if (command.off) {
993
+ runtime.communicationLogger.setDirectory(undefined);
994
+ append(systemLine("model communication logging disabled"));
995
+ return;
996
+ }
997
+ if (!command.path || !path.isAbsolute(command.path)) {
998
+ append({ kind: "error", text: "usage: /log <absolute-directory> or /log off" });
999
+ return;
1000
+ }
1001
+ await fs.mkdir(command.path, { recursive: true });
1002
+ runtime.communicationLogger.setDirectory(command.path);
1003
+ append(systemLine(`model communication logs: ${path.resolve(command.path)}`));
1004
+ }
1005
+ async function handleModelCommand(command, runtime) {
1006
+ const current = runtime.engine.getModelSettings();
1007
+ const nextModel = command.model ?? current.model;
1008
+ const validationError = validateModelReasoningArgument(nextModel, command.reasoning);
1009
+ if (validationError)
1010
+ return { kind: "error", text: validationError };
1011
+ const reasoningUpdate = resolveModelReasoningUpdate(command.reasoning, current.reasoning, nextModel, command.model !== undefined);
1012
+ const changed = command.model !== undefined || command.reasoning !== undefined;
1013
+ if (changed) {
1014
+ runtime.engine.setModel(nextModel, reasoningUpdate.reasoning, reasoningUpdate.update);
1015
+ try {
1016
+ const { providerChanged } = await persistModelCommandSettings(runtime, command, reasoningUpdate);
1017
+ if (providerChanged) {
1018
+ const config = readModelProviderConfig(process.env);
1019
+ if (config) {
1020
+ const innerGateway = createModelGatewayFromConfig(config);
1021
+ runtime.modelGateway.setInner(innerGateway);
1022
+ runtime.agentRuntime.modelGateway = runtime.modelGateway;
1023
+ runtime.engine.setModelProvider({ modelGateway: runtime.modelGateway, model: config.model, fallbackModel: config.fallbackModel, reasoning: config.defaultReasoning });
1024
+ runtime.defaultReasoning = config.defaultReasoning;
1025
+ }
1026
+ }
1027
+ }
1028
+ catch (error) {
1029
+ return { kind: "error", text: `Model settings changed for this session, but saving to ${runtime.envPath} failed: ${error instanceof Error ? error.message : String(error)}` };
1030
+ }
1031
+ }
1032
+ const settings = formatModelSettings(runtime.engine.getModelSettings(), runtime.defaultReasoning);
1033
+ return systemLine(changed ? `${settings}\nSaved to ${runtime.envPath}` : settings);
1034
+ }
1035
+ function resolveModelReasoningUpdate(value, current, modelId, modelChanged) {
1036
+ if (value === "off")
1037
+ return { reasoning: null, update: true };
1038
+ if (value === "default")
1039
+ return { reasoning: undefined, update: true };
1040
+ if (value !== undefined)
1041
+ return { reasoning: { effort: value }, update: true };
1042
+ if (modelChanged && current?.effort && !reasoningEffortsForModel(modelId)?.includes(current.effort))
1043
+ return { reasoning: undefined, update: true };
1044
+ return { reasoning: current, update: false };
1045
+ }
1046
+ async function persistModelCommandSettings(runtime, command, reasoningUpdate) {
1047
+ const currentProvider = currentModelProvider();
1048
+ let targetProvider = currentProvider;
1049
+ const updates = {};
1050
+ if (command.model !== undefined) {
1051
+ const metadata = findModelMetadata(command.model);
1052
+ if (metadata) {
1053
+ const modelProvider = parseLoginProvider(metadata.provider);
1054
+ if (modelProvider) {
1055
+ targetProvider = modelProvider;
1056
+ if (targetProvider !== currentProvider)
1057
+ updates.MODEL_PROVIDER = targetProvider;
1058
+ }
1059
+ }
1060
+ updates[modelEnvKeyForProvider(targetProvider)] = command.model.trim() || undefined;
1061
+ }
1062
+ if (command.reasoning !== undefined || reasoningUpdate.update) {
1063
+ updates.MODEL_REASONING_EFFORT = envValueForReasoning(reasoningUpdate.reasoning);
1064
+ updates.MODEL_REASONING_SUMMARY = undefined;
1065
+ }
1066
+ if (Object.keys(updates).length === 0)
1067
+ return { providerChanged: false };
1068
+ await writeEnvUpdates(runtime.envPath, updates);
1069
+ applyEnvUpdatesToProcess(updates);
1070
+ runtime.defaultReasoning = reasoningUpdate.update ? reasoningUpdate.reasoning : runtime.defaultReasoning;
1071
+ return { providerChanged: targetProvider !== currentProvider };
1072
+ }
1073
+ function currentModelProvider() {
1074
+ return parseLoginProvider(process.env.MODEL_PROVIDER) ?? "openai";
1075
+ }
1076
+ function parseLoginProvider(value) {
1077
+ if (value === "openai" || value === "deepseek" || value === "kimi")
1078
+ return value;
1079
+ return undefined;
1080
+ }
1081
+ const LOGIN_PROVIDERS = ["openai", "deepseek", "kimi"];
1082
+ const SHARED_LOGIN_FIELDS = [
1083
+ { key: "reasoningEffort", label: "Reasoning effort", envKey: "MODEL_REASONING_EFFORT", scope: "shared", options: ["", "off", "none", "minimal", "low", "medium", "high", "xhigh", "max"] },
1084
+ { key: "reasoningSummary", label: "Reasoning summary", envKey: "MODEL_REASONING_SUMMARY", scope: "shared", options: ["", "auto", "concise", "detailed"] },
1085
+ { key: "maxOutputTokens", label: "Max output tokens", envKey: "MODEL_MAX_OUTPUT_TOKENS", scope: "shared", placeholder: "800" },
1086
+ { key: "timeoutMs", label: "Timeout ms", envKey: "MODEL_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
1087
+ { key: "streamIdleTimeoutMs", label: "Stream idle timeout ms", envKey: "MODEL_STREAM_IDLE_TIMEOUT_MS", scope: "shared", placeholder: "120000" },
1088
+ { key: "maxRetries", label: "Max retries", envKey: "MODEL_MAX_RETRIES", scope: "shared", placeholder: "2" },
1089
+ ];
1090
+ const LOGIN_FIELD_DEFINITIONS = {
1091
+ openai: [
1092
+ { key: "apiKey", label: "API key", envKey: "OPENAI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
1093
+ { key: "baseUrl", label: "Base URL", envKey: "OPENAI_BASE_URL", scope: "provider", placeholder: "https://api.openai.com" },
1094
+ { key: "model", label: "Model", envKey: "OPENAI_MODEL", scope: "provider", required: true, placeholder: "gpt-5.5" },
1095
+ { key: "fallbackModel", label: "Fallback model", envKey: "OPENAI_FALLBACK_MODEL", scope: "provider" },
1096
+ { key: "endpoint", label: "Endpoint", envKey: "OPENAI_ENDPOINT", scope: "provider", placeholder: "auto", options: ["auto", "responses", "chat"] },
1097
+ ...SHARED_LOGIN_FIELDS,
1098
+ ],
1099
+ deepseek: [
1100
+ { key: "apiKey", label: "API key", envKey: "DEEPSEEK_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
1101
+ { key: "baseUrl", label: "Base URL", envKey: "DEEPSEEK_BASE_URL", scope: "provider", placeholder: "https://api.deepseek.com" },
1102
+ { key: "model", label: "Model", envKey: "DEEPSEEK_MODEL", scope: "provider", required: true, placeholder: "deepseek-chat" },
1103
+ { key: "fallbackModel", label: "Fallback model", envKey: "DEEPSEEK_FALLBACK_MODEL", scope: "provider" },
1104
+ ...SHARED_LOGIN_FIELDS,
1105
+ ],
1106
+ kimi: [
1107
+ { key: "apiKey", label: "API key", envKey: "KIMI_API_KEY", scope: "provider", required: true, secret: true, placeholder: "sk-..." },
1108
+ { key: "baseUrl", label: "Base URL", envKey: "KIMI_BASE_URL", scope: "provider", placeholder: "https://api.moonshot.cn/v1" },
1109
+ { key: "model", label: "Model", envKey: "KIMI_MODEL", scope: "provider", required: true, placeholder: "kimi-k2.6" },
1110
+ { key: "fallbackModel", label: "Fallback model", envKey: "KIMI_FALLBACK_MODEL", scope: "provider" },
1111
+ ...SHARED_LOGIN_FIELDS,
1112
+ ],
1113
+ };
1114
+ const DEPRECATED_MODEL_ENV_KEYS = [
1115
+ "MODEL_API_KEY", "MODEL_BASE_URL", "MODEL_ID", "MODEL_FALLBACK_ID", "MODEL_ENDPOINT", "OPENAI_PROVIDER",
1116
+ "OPENAI_REASONING_EFFORT", "OPENAI_REASONING_SUMMARY", "OPENAI_MAX_OUTPUT_TOKENS", "OPENAI_TIMEOUT_MS", "OPENAI_STREAM_IDLE_TIMEOUT_MS", "OPENAI_MAX_RETRIES",
1117
+ "DEEPSEEK_REASONING_EFFORT", "DEEPSEEK_REASONING_SUMMARY", "DEEPSEEK_MAX_OUTPUT_TOKENS", "DEEPSEEK_TIMEOUT_MS", "DEEPSEEK_STREAM_IDLE_TIMEOUT_MS", "DEEPSEEK_MAX_RETRIES",
1118
+ "KIMI_REASONING_EFFORT", "KIMI_REASONING_SUMMARY", "KIMI_MAX_OUTPUT_TOKENS", "KIMI_TIMEOUT_MS", "KIMI_STREAM_IDLE_TIMEOUT_MS", "KIMI_MAX_RETRIES",
1119
+ "MOONSHOT_REASONING_EFFORT", "MOONSHOT_REASONING_SUMMARY", "MOONSHOT_MAX_OUTPUT_TOKENS", "MOONSHOT_TIMEOUT_MS", "MOONSHOT_STREAM_IDLE_TIMEOUT_MS", "MOONSHOT_MAX_RETRIES",
1120
+ ];
1121
+ function createLoginFormPayload(envPath, provider) {
1122
+ const env = parseEnvFileSafe(envPath);
1123
+ const selectedProvider = provider ?? parseLoginProvider(env.MODEL_PROVIDER ?? process.env.MODEL_PROVIDER) ?? guessLoginProvider(env);
1124
+ return {
1125
+ envPath,
1126
+ providers: LOGIN_PROVIDERS,
1127
+ provider: selectedProvider,
1128
+ fields: LOGIN_FIELD_DEFINITIONS[selectedProvider],
1129
+ values: loginValuesForProvider(selectedProvider, env),
1130
+ };
1131
+ }
1132
+ function loginValuesForProvider(provider, env) {
1133
+ const values = {};
1134
+ for (const field of LOGIN_FIELD_DEFINITIONS[provider])
1135
+ values[field.key] = env[field.envKey] ?? "";
1136
+ if (provider === "kimi") {
1137
+ values.apiKey ||= env.MOONSHOT_API_KEY ?? process.env.MOONSHOT_API_KEY ?? "";
1138
+ values.baseUrl ||= env.MOONSHOT_BASE_URL ?? process.env.MOONSHOT_BASE_URL ?? "";
1139
+ values.model ||= env.MOONSHOT_MODEL ?? process.env.MOONSHOT_MODEL ?? "";
1140
+ values.fallbackModel ||= env.MOONSHOT_FALLBACK_MODEL ?? process.env.MOONSHOT_FALLBACK_MODEL ?? "";
1141
+ }
1142
+ if (!values.baseUrl)
1143
+ values.baseUrl = defaultBaseUrlForLoginProvider(provider);
1144
+ if (!values.model)
1145
+ values.model = defaultModelForLoginProvider(provider);
1146
+ if (provider === "openai" && !values.endpoint)
1147
+ values.endpoint = "auto";
1148
+ return values;
1149
+ }
1150
+ function guessLoginProvider(env) {
1151
+ if (env.KIMI_API_KEY ?? env.MOONSHOT_API_KEY ?? process.env.KIMI_API_KEY ?? process.env.MOONSHOT_API_KEY)
1152
+ return "kimi";
1153
+ if (env.DEEPSEEK_API_KEY ?? process.env.DEEPSEEK_API_KEY)
1154
+ return "deepseek";
1155
+ return currentModelProvider();
1156
+ }
1157
+ function defaultBaseUrlForLoginProvider(provider) {
1158
+ if (provider === "deepseek")
1159
+ return "https://api.deepseek.com";
1160
+ if (provider === "kimi")
1161
+ return "https://api.moonshot.cn/v1";
1162
+ return "https://api.openai.com";
1163
+ }
1164
+ function defaultModelForLoginProvider(provider) {
1165
+ if (provider === "deepseek")
1166
+ return "deepseek-chat";
1167
+ if (provider === "kimi")
1168
+ return "kimi-k2.6";
1169
+ return "gpt-5.5";
1170
+ }
1171
+ function validateLoginFormPayload(payload) {
1172
+ for (const field of LOGIN_FIELD_DEFINITIONS[payload.provider]) {
1173
+ const value = (payload.values[field.key] ?? "").trim();
1174
+ if (field.required && !value)
1175
+ return `${field.label} is required.`;
1176
+ if (field.options?.length && value && !field.options.includes(value))
1177
+ return `${field.label} must be one of: ${field.options.filter(Boolean).join(", ")}`;
1178
+ }
1179
+ for (const fieldKey of ["maxOutputTokens", "timeoutMs", "streamIdleTimeoutMs", "maxRetries"]) {
1180
+ const value = payload.values[fieldKey]?.trim();
1181
+ if (value && !Number.isFinite(Number(value)))
1182
+ return `${fieldKey} must be a number.`;
1183
+ }
1184
+ return undefined;
1185
+ }
1186
+ async function saveLoginPayloadToEnv(payload) {
1187
+ await writeEnvUpdates(payload.envPath, envEntriesForLoginPayload(payload), DEPRECATED_MODEL_ENV_KEYS);
1188
+ }
1189
+ function applyLoginPayloadToProcessEnv(payload) {
1190
+ applyEnvUpdatesToProcess(envEntriesForLoginPayload(payload));
1191
+ for (const key of DEPRECATED_MODEL_ENV_KEYS)
1192
+ delete process.env[key];
1193
+ }
1194
+ function envEntriesForLoginPayload(payload) {
1195
+ const entries = { MODEL_PROVIDER: payload.provider };
1196
+ for (const field of LOGIN_FIELD_DEFINITIONS[payload.provider]) {
1197
+ const value = (payload.values[field.key] ?? "").trim();
1198
+ entries[field.envKey] = value || undefined;
1199
+ }
1200
+ if (payload.provider === "kimi") {
1201
+ entries.MOONSHOT_API_KEY = undefined;
1202
+ entries.MOONSHOT_BASE_URL = undefined;
1203
+ entries.MOONSHOT_MODEL = undefined;
1204
+ entries.MOONSHOT_FALLBACK_MODEL = undefined;
1205
+ }
1206
+ return entries;
1207
+ }
1208
+ function parseEnvFileSafe(envPath) {
1209
+ if (!existsSync(envPath))
1210
+ return {};
1211
+ const env = {};
1212
+ for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) {
1213
+ const parsed = parseEnvLine(line);
1214
+ if (parsed)
1215
+ env[parsed.key] = stripEnvQuotes(parsed.value.trim());
1216
+ }
1217
+ return env;
1218
+ }
1219
+ function stripEnvQuotes(value) {
1220
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
1221
+ return value.slice(1, -1);
1222
+ return value;
1223
+ }
1224
+ function modelEnvKeyForProvider(provider) {
1225
+ if (provider === "deepseek")
1226
+ return "DEEPSEEK_MODEL";
1227
+ if (provider === "kimi")
1228
+ return "KIMI_MODEL";
1229
+ return "OPENAI_MODEL";
1230
+ }
1231
+ function envValueForReasoning(reasoning) {
1232
+ if (reasoning === null)
1233
+ return "off";
1234
+ return reasoning?.effort;
1235
+ }
1236
+ async function writeEnvUpdates(envPath, updates, removeKeys = []) {
1237
+ await fs.mkdir(path.dirname(envPath), { recursive: true });
1238
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
1239
+ const next = updateEnvContent(existing, updates, removeKeys);
1240
+ await fs.writeFile(envPath, next, "utf8");
1241
+ }
1242
+ function updateEnvContent(existing, updates, removeKeys = []) {
1243
+ const keys = new Set(Object.keys(updates));
1244
+ const removals = new Set(removeKeys);
1245
+ const seen = new Set();
1246
+ const lines = existing ? existing.split(/\r?\n/) : [];
1247
+ const next = lines.map((line) => {
1248
+ const parsed = parseEnvLine(line);
1249
+ if (!parsed)
1250
+ return line;
1251
+ if (removals.has(parsed.key) && !keys.has(parsed.key))
1252
+ return undefined;
1253
+ if (!keys.has(parsed.key))
1254
+ return line;
1255
+ seen.add(parsed.key);
1256
+ const value = updates[parsed.key];
1257
+ return value === undefined ? undefined : `${parsed.key}=${formatEnvValue(value)}`;
1258
+ }).filter((line) => line !== undefined);
1259
+ for (const [key, value] of Object.entries(updates)) {
1260
+ if (seen.has(key) || value === undefined)
1261
+ continue;
1262
+ next.push(`${key}=${formatEnvValue(value)}`);
1263
+ }
1264
+ return `${next.join("\n").replace(/\n*$/u, "")}\n`;
1265
+ }
1266
+ function parseEnvLine(line) {
1267
+ const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
1268
+ if (!match)
1269
+ return undefined;
1270
+ return { key: match[1], value: match[2] };
1271
+ }
1272
+ function formatEnvValue(value) {
1273
+ if (/^[A-Za-z0-9_./:@-]*$/.test(value))
1274
+ return value;
1275
+ return JSON.stringify(value);
1276
+ }
1277
+ function formatResume(snapshot) {
1278
+ return `resumed session ${snapshot.sessionId}: ${snapshot.resumedMessages} messages from ${snapshot.transcriptPath}`;
1279
+ }
1280
+ function applyEnvUpdatesToProcess(updates) {
1281
+ for (const [key, value] of Object.entries(updates)) {
1282
+ if (value === undefined)
1283
+ delete process.env[key];
1284
+ else
1285
+ process.env[key] = value;
1286
+ }
1287
+ }
1288
+ function validateModelReasoningArgument(modelId, reasoning) {
1289
+ if (!reasoning || reasoning === "default" || reasoning === "off")
1290
+ return undefined;
1291
+ if (!modelId)
1292
+ return `Cannot set reasoning effort '${reasoning}' without a configured model. Choose a model first.`;
1293
+ const efforts = reasoningEffortsForModel(modelId);
1294
+ if (!efforts?.length)
1295
+ return `Model ${modelId} has no configured reasoning effort support; not setting '${reasoning}'.`;
1296
+ if (!efforts.includes(reasoning))
1297
+ return `Model ${modelId} supports reasoning efforts: ${efforts.join(", ")}; not '${reasoning}'.`;
1298
+ return undefined;
1299
+ }
1300
+ function formatModelSettings(settings, defaultReasoning) {
1301
+ const window = resolveContextWindowTokens(settings.model);
1302
+ const lines = ["Model settings:", ` Model: ${settings.model ?? "<provider default>"}`];
1303
+ if (settings.fallbackModel)
1304
+ lines.push(` Fallback: ${settings.fallbackModel}`);
1305
+ lines.push(` Reasoning effort: ${formatReasoningSetting(settings.reasoning)}`);
1306
+ if (defaultReasoning?.effort)
1307
+ lines.push(` Env default reasoning: ${defaultReasoning.effort}`);
1308
+ if (window.model) {
1309
+ const efforts = reasoningEffortsForModel(settings.model);
1310
+ lines.push(` Context window: ${window.tokens ? formatNumber(window.tokens) : "?"} tokens`);
1311
+ lines.push(` Supports reasoning: ${window.model.reasoning ? "yes" : "no"}`);
1312
+ lines.push(` Reasoning efforts: ${efforts?.length ? efforts.join(", ") : "<not configurable>"}`);
1313
+ lines.push(` Image input: ${window.model.imageInput ? "yes" : "no"}`);
1314
+ }
1315
+ return lines.join("\n");
1316
+ }
1317
+ function formatReasoningSetting(reasoning) {
1318
+ if (reasoning === null)
1319
+ return "off";
1320
+ return reasoning?.effort ?? "default";
1321
+ }
1322
+ function renderMessage(message, append, activeAssistantId, options = {}) {
1323
+ if (message.metadata?.syntheticToolUse === true)
1324
+ return false;
1325
+ if (message.role === "progress" || message.isMeta)
1326
+ return false;
1327
+ if (message.role === "assistant" && activeAssistantId !== undefined && message.blocks.some((block) => block.type === "text"))
1328
+ return true;
1329
+ let rendered = false;
1330
+ for (const block of message.blocks) {
1331
+ if (block.type === "text") {
1332
+ const kind = kindForRole(message.role);
1333
+ if (kind === "meta")
1334
+ continue;
1335
+ if (kind === "system")
1336
+ append({ kind, title: titleForRole(message.role), text: block.text, previewStyle: "summary" });
1337
+ else
1338
+ append({ kind, text: block.text });
1339
+ rendered = true;
1340
+ }
1341
+ else if (block.type === "image") {
1342
+ const kind = kindForRole(message.role);
1343
+ if (kind === "meta")
1344
+ continue;
1345
+ append({ kind, text: block.label ?? `[image ${block.mimeType}]` });
1346
+ rendered = true;
1347
+ }
1348
+ else if (block.type === "thinking") {
1349
+ append(thinkingLine(block.text));
1350
+ rendered = true;
1351
+ }
1352
+ else if (block.type === "tool_use" && options.includeToolUseBlocks) {
1353
+ append({ ...formatToolUse(block), live: false });
1354
+ rendered = true;
1355
+ }
1356
+ else if (block.type === "tool_result") {
1357
+ append(formatToolResultLine(block.name, block.output, block.ok));
1358
+ rendered = true;
1359
+ }
1360
+ }
1361
+ return rendered;
1362
+ }
1363
+ function renderToolResultMessage(message, append, replaceLine, activeToolLineIds) {
1364
+ let rendered = false;
1365
+ for (const block of message.blocks) {
1366
+ if (block.type !== "tool_result")
1367
+ continue;
1368
+ const line = formatToolResultLine(block.name, block.output, block.ok);
1369
+ const id = activeToolLineIds.get(block.toolUseId);
1370
+ if (id === undefined)
1371
+ append(line);
1372
+ else {
1373
+ replaceLine(id, { ...line, title: toolTitle(block.name, "finished"), live: false, pendingReplacement: false });
1374
+ activeToolLineIds.delete(block.toolUseId);
1375
+ }
1376
+ rendered = true;
1377
+ }
1378
+ return rendered;
1379
+ }
1380
+ function assistantText(message) {
1381
+ const text = message.blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
1382
+ return text.length > 0 ? text : undefined;
1383
+ }
1384
+ function thinkingText(message) {
1385
+ const text = message.blocks.filter((block) => block.type === "thinking").map((block) => block.text).join("");
1386
+ return text.length > 0 ? text : undefined;
1387
+ }
1388
+ function kindForRole(role) {
1389
+ if (role === "user")
1390
+ return "user";
1391
+ if (role === "assistant")
1392
+ return "assistant";
1393
+ if (role === "tool_result")
1394
+ return "tool";
1395
+ if (role === "progress")
1396
+ return "meta";
1397
+ if (role === "system")
1398
+ return "meta";
1399
+ return "system";
1400
+ }
1401
+ function titleForRole(role) {
1402
+ if (role === "progress")
1403
+ return "Meta";
1404
+ if (role === "system")
1405
+ return "System";
1406
+ if (role === "tool_result")
1407
+ return "Tool result";
1408
+ return titleForKind(kindForRole(role));
1409
+ }
1410
+ function titleForKind(kind) {
1411
+ if (kind === "thinking")
1412
+ return "think";
1413
+ if (kind === "tool")
1414
+ return "Tool";
1415
+ if (kind === "error")
1416
+ return "Error";
1417
+ if (kind === "meta")
1418
+ return "Meta";
1419
+ if (kind === "system")
1420
+ return "System";
1421
+ if (kind === "user")
1422
+ return "User";
1423
+ return "Assistant";
1424
+ }
1425
+ function systemLine(text, summaryMaxLines) {
1426
+ return { kind: "system", title: "System", text, previewStyle: "summary", summaryMaxLines };
1427
+ }
1428
+ function thinkingLine(text, live = false) {
1429
+ return { kind: "thinking", title: titleForKind("thinking"), text, previewStyle: "summary", summaryMaxLines: THINKING_SUMMARY_MAX_LINES, live };
1430
+ }
1431
+ function formatToolUse(toolUse) {
1432
+ if (toolUse.name === "plan" && isPlanToolPayload(toolUse.input))
1433
+ return { kind: "tool", title: toolTitle(toolUse.name, "running"), bodyTitle: planToolBodyTitle(toolUse.input), text: formatPlanToolPayload(toolUse.input), collapsible: true };
1434
+ return { kind: "tool", title: toolTitle(toolUse.name, "running"), text: formatReplData(toolUse.input, 1200), previewStyle: "summary", collapsible: true };
1435
+ }
1436
+ function formatToolResultLine(toolName, output, ok) {
1437
+ const formatted = formatToolResult(toolName, output, ok);
1438
+ return { kind: ok ? "tool" : "error", title: toolTitle(toolName, "finished"), bodyTitle: formatted.bodyTitle, titleStatus: ok ? "success" : "failure", text: formatted.text, format: formatted.format, live: false, previewStyle: formatted.full ? undefined : "summary", summaryMaxLines: formatted.summaryMaxLines, collapsible: true };
1439
+ }
1440
+ function formatToolFinishedWithoutResult(toolUse, ok) {
1441
+ const inputText = formatReplData(toolUse.input, 1200);
1442
+ return { kind: ok ? "tool" : "error", title: toolTitle(toolUse.name, "finished"), titleStatus: ok ? "success" : "failure", text: inputText ? `${ok ? "finished" : "failed"}\n${inputText}` : ok ? "finished" : "failed", previewStyle: "summary", live: true, pendingReplacement: true, collapsible: true };
1443
+ }
1444
+ function toolTitle(toolName, _phase) {
1445
+ return toolName;
1446
+ }
1447
+ function isPlanToolPayload(value) {
1448
+ if (!isRecord(value) || !Array.isArray(value.items))
1449
+ return false;
1450
+ return value.items.every((item) => isRecord(item) && typeof item.description === "string" && (item.status === "pending" || item.status === "in_progress" || item.status === "completed"));
1451
+ }
1452
+ function planToolBodyTitle(payload) {
1453
+ const title = payload.title?.trim();
1454
+ return title ? title : undefined;
1455
+ }
1456
+ function formatPlanToolPayload(payload) {
1457
+ const sections = [];
1458
+ if (payload.summary?.trim())
1459
+ sections.push(payload.summary.trim());
1460
+ if (payload.note?.trim())
1461
+ sections.push(payload.note.trim());
1462
+ sections.push(payload.items.map((item) => item.status === "completed" ? `- ~~${item.description.trim()}~~` : item.status === "in_progress" ? `- ▶ ${item.description.trim()}` : `- ${item.description.trim()}`).join("\n"));
1463
+ return sections.filter(Boolean).join("\n");
1464
+ }
1465
+ function formatToolResult(toolName, output, ok) {
1466
+ if ((toolName === "edit" || toolName === "write") && isRecord(output) && isEditToolOutput(output))
1467
+ return { text: formatEditToolDiff(output, ok), format: "diff", summaryMaxLines: EDIT_TOOL_SUMMARY_MAX_LINES };
1468
+ if (isExecOutput(output))
1469
+ return { text: formatExecToolResult(output, ok), format: "plain", summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
1470
+ if (toolName === "list" && isRecord(output))
1471
+ return { text: formatListToolResult(output, ok) };
1472
+ if (toolName === "read" && isRecord(output))
1473
+ return { text: formatReadToolResult(output, ok) };
1474
+ if (toolName === "grep" && isRecord(output))
1475
+ return { text: formatGrepToolResult(output, ok) };
1476
+ if (toolName === "search" && isRecord(output))
1477
+ return { text: formatWebSearchToolResult(output, ok), summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
1478
+ if (toolName === "plan" && isPlanToolPayload(output))
1479
+ return { text: formatPlanToolPayload(output), full: true, bodyTitle: planToolBodyTitle(output) };
1480
+ if (typeof output === "string")
1481
+ return { text: output, format: hasAnsi(output) ? "ansi" : undefined, summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
1482
+ return { text: `${ok ? "ok" : "failed"}\n${formatReplData(output, 6000)}`, summaryMaxLines: EXPANDED_SUMMARY_MAX_LINES };
1483
+ }
1484
+ function isEditToolOutput(value) {
1485
+ return typeof value.path === "string" && typeof value.operation === "string" && typeof value.replacements === "number" && Array.isArray(value.patch) && value.patch.every(isEditPatchHunk);
1486
+ }
1487
+ function isEditPatchHunk(value) {
1488
+ return isRecord(value) && typeof value.oldStart === "number" && typeof value.oldLines === "number" && typeof value.newStart === "number" && typeof value.newLines === "number" && Array.isArray(value.lines) && value.lines.every((line) => typeof line === "string");
1489
+ }
1490
+ function formatEditToolDiff(output, ok) {
1491
+ const lines = [
1492
+ `${ok ? output.operation : "failed"} ${output.path}, ${output.replacements} replacement(s)`,
1493
+ `--- ${output.path}`,
1494
+ `+++ ${output.path}`,
1495
+ ];
1496
+ for (const hunk of output.patch) {
1497
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
1498
+ lines.push(...formatEditPatchHunkLines(hunk));
1499
+ }
1500
+ if (output.patch.length === 0)
1501
+ lines.push("no changes");
1502
+ return lines.join("\n");
1503
+ }
1504
+ function formatEditPatchHunkLines(hunk) {
1505
+ const oldLineWidth = diffLineNumberWidth(hunk.oldStart, hunk.oldLines);
1506
+ const newLineWidth = diffLineNumberWidth(hunk.newStart, hunk.newLines);
1507
+ let oldLineNumber = hunk.oldStart;
1508
+ let newLineNumber = hunk.newStart;
1509
+ return hunk.lines.map((rawLine) => {
1510
+ const marker = diffLineMarker(rawLine);
1511
+ if (!marker)
1512
+ return rawLine;
1513
+ const showOldLineNumber = marker !== "+";
1514
+ const showNewLineNumber = marker !== "-";
1515
+ const oldLineLabel = showOldLineNumber ? String(oldLineNumber).padStart(oldLineWidth) : " ".repeat(oldLineWidth);
1516
+ const newLineLabel = showNewLineNumber ? String(newLineNumber).padStart(newLineWidth) : " ".repeat(newLineWidth);
1517
+ if (showOldLineNumber)
1518
+ oldLineNumber += 1;
1519
+ if (showNewLineNumber)
1520
+ newLineNumber += 1;
1521
+ return `${oldLineLabel} ${newLineLabel} │ ${marker}${rawLine.slice(1)}`;
1522
+ });
1523
+ }
1524
+ function diffLineNumberWidth(start, lineCount) {
1525
+ const end = lineCount > 0 ? start + lineCount - 1 : start;
1526
+ return Math.max(String(start).length, String(end).length, 2);
1527
+ }
1528
+ function diffLineMarker(line) {
1529
+ const marker = line[0];
1530
+ return marker === "+" || marker === "-" || marker === " " ? marker : undefined;
1531
+ }
1532
+ function isExecOutput(value) {
1533
+ return isRecord(value) && typeof value.command === "string" && typeof value.durationMs === "number";
1534
+ }
1535
+ function formatExecToolResult(output, ok) {
1536
+ const status = output.timedOut ? "timed out" : output.exitCode === 0 ? "exit 0" : `exit ${output.exitCode ?? output.signal ?? "unknown"}`;
1537
+ const lines = ["exec result", `status: ${status}`, `duration: ${output.durationMs}ms`, `command: ${output.command}`];
1538
+ const stdout = typeof output.stdout === "string" ? output.stdout.replace(/\s+$/u, "") : "";
1539
+ const stderr = typeof output.stderr === "string" ? output.stderr.replace(/\s+$/u, "") : "";
1540
+ if (stdout)
1541
+ lines.push("stdout:", stdout);
1542
+ if (stderr)
1543
+ lines.push("stderr:", stderr);
1544
+ if (!stdout && !stderr)
1545
+ lines.push(ok ? "output: (none)" : "output: (not captured)");
1546
+ return lines.join("\n");
1547
+ }
1548
+ function formatListToolResult(output, ok) {
1549
+ const pathValue = typeof output.path === "string" ? output.path : "";
1550
+ const typeValue = typeof output.type === "string" ? output.type : "result";
1551
+ const returnedEntries = typeof output.returnedEntries === "number" ? output.returnedEntries : undefined;
1552
+ const totalFiles = typeof output.totalFiles === "number" ? output.totalFiles : undefined;
1553
+ const totalDirectories = typeof output.totalDirectories === "number" ? output.totalDirectories : undefined;
1554
+ const entries = Array.isArray(output.entries) ? output.entries : [];
1555
+ const names = entries.map((entry) => (isRecord(entry) && typeof entry.name === "string" ? entry.name : undefined)).filter((name) => Boolean(name)).slice(0, 5);
1556
+ const lines = [ok ? "list result" : "failed"];
1557
+ if (pathValue)
1558
+ lines.push(`path: ${pathValue}`);
1559
+ lines.push(`type: ${typeValue}`);
1560
+ const counts = [returnedEntries !== undefined ? `${returnedEntries} shown` : undefined, totalFiles !== undefined ? `${totalFiles} files` : undefined, totalDirectories !== undefined ? `${totalDirectories} dirs` : undefined].filter((value) => Boolean(value));
1561
+ if (counts.length > 0)
1562
+ lines.push(`entries: ${counts.join(" · ")}`);
1563
+ if (names.length > 0)
1564
+ lines.push("sample:", ...names.map((name) => ` ${name}`));
1565
+ return lines.join("\n");
1566
+ }
1567
+ function formatReadToolResult(output, ok) {
1568
+ const error = typeof output.error === "string" ? output.error : undefined;
1569
+ if (!ok || error)
1570
+ return ["failed", error ?? formatReplData(output, 1200)].join("\n");
1571
+ const pathValue = typeof output.path === "string" ? output.path : undefined;
1572
+ const startLine = typeof output.startLine === "number" ? output.startLine : undefined;
1573
+ const endLine = typeof output.endLine === "number" ? output.endLine : undefined;
1574
+ const totalLines = typeof output.totalLines === "number" ? output.totalLines : undefined;
1575
+ const hasMoreBefore = output.hasMoreBefore === true;
1576
+ const hasMoreAfter = output.hasMoreAfter === true;
1577
+ const content = typeof output.content === "string" ? output.content.trimEnd() : "";
1578
+ const lines = ["read result"];
1579
+ if (pathValue)
1580
+ lines.push(`file: ${pathValue}`);
1581
+ if (startLine !== undefined && endLine !== undefined && totalLines !== undefined) {
1582
+ const more = [hasMoreBefore ? "more before" : undefined, hasMoreAfter ? "more after" : undefined].filter((value) => Boolean(value)).join(", ");
1583
+ lines.push(`range: lines ${startLine}-${endLine} of ${totalLines}${more ? ` (${more})` : ""}`);
1584
+ }
1585
+ lines.push("content:", content || "(empty range)");
1586
+ return lines.join("\n");
1587
+ }
1588
+ function formatWebSearchToolResult(output, ok) {
1589
+ const error = typeof output.error === "string" ? output.error : undefined;
1590
+ if (!ok || error)
1591
+ return ["failed", error ?? formatReplData(output, 1200)].join("\n");
1592
+ const provider = typeof output.provider === "string" ? output.provider : "unknown";
1593
+ const query = typeof output.query === "string" ? output.query : "";
1594
+ const returnedResults = typeof output.returnedResults === "number" ? output.returnedResults : undefined;
1595
+ const results = Array.isArray(output.results) ? output.results : [];
1596
+ const lines = [`${returnedResults ?? results.length} web result(s) via ${provider}`];
1597
+ if (query)
1598
+ lines.push(`query: ${query}`);
1599
+ if (output.truncated === true)
1600
+ lines.push("truncated");
1601
+ if (results.length === 0)
1602
+ return [...lines, "no results"].join("\n");
1603
+ results.slice(0, 8).forEach((item, index) => {
1604
+ if (!isRecord(item))
1605
+ return;
1606
+ const title = typeof item.title === "string" && item.title.trim() ? item.title.trim() : "Untitled";
1607
+ const url = typeof item.url === "string" ? item.url : "";
1608
+ const published = typeof item.published === "string" ? ` · ${item.published}` : "";
1609
+ lines.push(`[${index + 1}] ${title}${published}`);
1610
+ if (url)
1611
+ lines.push(url);
1612
+ const highlights = Array.isArray(item.highlights) ? item.highlights.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
1613
+ const snippet = highlights[0] ?? (typeof item.text === "string" ? item.text : undefined);
1614
+ if (snippet)
1615
+ lines.push(truncate(snippet.replace(/\s+/gu, " "), 400));
1616
+ });
1617
+ return lines.join("\n");
1618
+ }
1619
+ function formatGrepToolResult(output, ok) {
1620
+ const error = typeof output.error === "string" ? output.error : undefined;
1621
+ if (!ok || error)
1622
+ return ["failed", error ?? formatReplData(output, 1200)].join("\n");
1623
+ const query = typeof output.query === "string" ? output.query : undefined;
1624
+ const grepPath = typeof output.grepPath === "string" ? output.grepPath : typeof output.path === "string" ? output.path : undefined;
1625
+ const returnedMatches = typeof output.returnedMatches === "number" ? output.returnedMatches : undefined;
1626
+ const totalMatchesKnown = typeof output.totalMatchesKnown === "number" ? output.totalMatchesKnown : undefined;
1627
+ const truncated = output.truncated === true;
1628
+ const matches = Array.isArray(output.matches) ? output.matches.filter(isGrepMatchLike) : [];
1629
+ const lines = ["grep result"];
1630
+ if (query !== undefined)
1631
+ lines.push(`query: ${query}`);
1632
+ if (grepPath !== undefined)
1633
+ lines.push(`path: ${grepPath}`);
1634
+ const countParts = [`${returnedMatches ?? matches.length} shown`, totalMatchesKnown !== undefined ? `${totalMatchesKnown} known` : undefined, truncated ? "truncated" : undefined].filter((value) => Boolean(value));
1635
+ lines.push(`matches: ${countParts.join(" · ")}`);
1636
+ if (matches.length === 0)
1637
+ return [...lines, "no matches"].join("\n");
1638
+ lines.push("results:");
1639
+ for (const match of matches)
1640
+ lines.push(formatGrepMatchLine(match));
1641
+ return lines.join("\n");
1642
+ }
1643
+ function isGrepMatchLike(value) {
1644
+ return isRecord(value) && typeof value.file === "string" && typeof value.line === "number" && typeof value.text === "string" && (value.column === undefined || typeof value.column === "number");
1645
+ }
1646
+ function formatGrepMatchLine(match) {
1647
+ const column = match.column !== undefined ? `:${match.column}` : "";
1648
+ return ` ${match.file}:${match.line}${column}: ${match.text}`;
1649
+ }
1650
+ function formatReplData(value, maxLength) {
1651
+ return truncate(formatReplValue(value), maxLength);
1652
+ }
1653
+ function formatReplValue(value, indent = 0, seen = new WeakSet()) {
1654
+ if (typeof value === "string")
1655
+ return value;
1656
+ if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
1657
+ return String(value);
1658
+ if (value === undefined)
1659
+ return "undefined";
1660
+ if (typeof value === "function")
1661
+ return `[Function${value.name ? `: ${value.name}` : ""}]`;
1662
+ if (typeof value === "symbol")
1663
+ return value.toString();
1664
+ if (value instanceof Date)
1665
+ return value.toISOString();
1666
+ if (value instanceof Error)
1667
+ return formatReplValue({ name: value.name, message: value.message, stack: value.stack }, indent, seen);
1668
+ if (Array.isArray(value))
1669
+ return formatReplArray(value, indent, seen);
1670
+ if (isRecord(value))
1671
+ return formatReplObject(value, indent, seen);
1672
+ return String(value);
1673
+ }
1674
+ function formatReplArray(value, indent, seen) {
1675
+ if (value.length === 0)
1676
+ return "[]";
1677
+ if (seen.has(value))
1678
+ return "[Circular]";
1679
+ seen.add(value);
1680
+ const pad = " ".repeat(indent);
1681
+ const childIndent = indent + 2;
1682
+ const lines = value.map((item) => isReplScalar(item) ? `${pad}- ${formatReplValue(item, childIndent, seen)}` : `${pad}-\n${formatReplValue(item, childIndent, seen)}`);
1683
+ seen.delete(value);
1684
+ return lines.join("\n");
1685
+ }
1686
+ function formatReplObject(value, indent, seen) {
1687
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined);
1688
+ if (entries.length === 0)
1689
+ return "{}";
1690
+ if (seen.has(value))
1691
+ return "[Circular]";
1692
+ seen.add(value);
1693
+ const pad = " ".repeat(indent);
1694
+ const childIndent = indent + 2;
1695
+ const lines = entries.map(([key, entryValue]) => {
1696
+ const label = `${pad}${key}:`;
1697
+ if (isReplScalar(entryValue))
1698
+ return `${label} ${formatReplValue(entryValue, childIndent, seen)}`;
1699
+ const formatted = formatReplValue(entryValue, childIndent, seen);
1700
+ return formatted === "[]" || formatted === "{}" || formatted === "[Circular]" ? `${label} ${formatted}` : `${label}\n${formatted}`;
1701
+ });
1702
+ seen.delete(value);
1703
+ return lines.join("\n");
1704
+ }
1705
+ function isReplScalar(value) {
1706
+ return value === null || value === undefined || typeof value !== "object" || value instanceof Date;
1707
+ }
1708
+ function isRecord(value) {
1709
+ return typeof value === "object" && value !== null;
1710
+ }
1711
+ function hasAnsi(value) {
1712
+ return /\x1b\[[0-9;]*m/.test(value);
1713
+ }
1714
+ function truncate(value, maxLength) {
1715
+ return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`;
1716
+ }
1717
+ function estimateTokens(text) {
1718
+ return text ? Math.max(1, Math.ceil(text.length / 4)) : 0;
1719
+ }
1720
+ function formatUsageTotals(totals) {
1721
+ const totalLabel = totals.computedTotalTokens ? "Total tokens (computed)" : "Total tokens";
1722
+ return [
1723
+ "Session usage:",
1724
+ ` Requests: ${formatNumber(totals.requests)}`,
1725
+ ` Input tokens: ${formatNumber(totals.inputTokens)}`,
1726
+ ` Output tokens: ${formatNumber(totals.outputTokens)}`,
1727
+ ` ${totalLabel}: ${formatNumber(totals.totalTokens)}`,
1728
+ ` Reasoning tokens: ${formatNumber(totals.reasoningTokens)}`,
1729
+ ` Cached input tokens: ${formatNumber(totals.cachedTokens)}`,
1730
+ ].join("\n");
1731
+ }
1732
+ function formatManualCompaction(result) {
1733
+ if (!result.changed)
1734
+ return "No context compaction was needed.";
1735
+ return `context compacted: ${result.messages.length} message(s) retained, ${formatNumber(result.tokensFreed ?? 0)} chars removed`;
1736
+ }
1737
+ function formatPureCompaction(result) {
1738
+ if (!result.changed)
1739
+ return "No context available to purify.";
1740
+ return `pure context compacted: ${result.messages.length} sanitized message(s) retained, ${formatNumber(result.tokensFreed ?? 0)} chars removed; raw command/log/code details omitted`;
1741
+ }
1742
+ function formatNumber(value) {
1743
+ return value === undefined ? "?" : new Intl.NumberFormat("en-US").format(Math.round(value));
1744
+ }
1745
+ const THINKING_SUMMARY_MAX_LINES = 1000;
1746
+ const EXPANDED_SUMMARY_MAX_LINES = 1000;
1747
+ const EDIT_TOOL_SUMMARY_MAX_LINES = EXPANDED_SUMMARY_MAX_LINES;
1748
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1749
+ runWebServer().catch((error) => {
1750
+ console.error(error instanceof Error ? error.stack ?? error.message : String(error));
1751
+ process.exitCode = 1;
1752
+ });
1753
+ }
1754
+ //# sourceMappingURL=index.js.map