@xingjisoft/neutrino 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
22
22
  - **Sub-agents** — independent work can be handed to a sub-agent: a second agent loop with its own context, its own tool calls and the same approval gate. Only its report comes back, so a survey that reads thirty files does not fill the conversation with thirty files. See [Sub-agents](#sub-agents).
23
23
  - **Agent history browser** — view past sessions from other CLI agents (Claude Code, Codex, OpenCode) directly from the history screen.
24
24
  - **Approval gate** — risky commands and paths outside the working directory need your sign-off. The header turns orange and reads **Request Approval**; press `Y` or `N`. Delete-class commands always ask, unless you explicitly granted full trust, and there is an opt-in reviewer agent for automatic decisions.
25
- - **Browser UI on by default** — every `neutrino` launch also mounts a local website at <http://127.0.0.1:8765> that lists every saved conversation grouped by working directory, streams the running one in real time, and lets you pause/resume it, send messages, switch provider/model/intensity, and edit settings from the browser. Use `--port`/`--host` to move it, `--lan` to share it, or `--no-web` to keep the terminal only.
25
+ - **Browser UI on by default** — every `neutrino` launch also mounts a local website at <http://127.0.0.1:8765> that lists every saved conversation grouped by working directory, streams the running one in real time, and lets you pause/resume it, send messages, start new conversations, open or create working directories, switch provider/model/intensity, edit every terminal setting, and switch the interface between English and 简体中文. Use `--port`/`--host` to move it, `--lan` to share it, or `--no-web` to keep the terminal only.
26
26
 
27
27
  ## Requirements
28
28
 
@@ -190,7 +190,8 @@ Settings are stored under `~/.neutrino/`:
190
190
  with `"reasoning": true` (or opt out with `false`).
191
191
  - `config.json` — app configuration (selected provider/model/intensity, theme,
192
192
  language, approval mode, `auto_approve_extreme_risk`, `sound_alerts`,
193
- `web_console`).
193
+ `web_console`, `web_workspaces`). `language` (`en` or `zh`) is the browser
194
+ console's interface language; the terminal UI itself stays English.
194
195
  - `session/` — saved conversation sessions, one JSON file per session.
195
196
 
196
197
  Set your API key in the model select screen or directly in `model.json`.
@@ -306,20 +307,44 @@ terminal UI starts. It groups every saved conversation by working directory
306
307
  (showing the five newest per path with the rest collapsed), folds the work of
307
308
  every finished turn into one summary line that opens on a click while the reply
308
309
  stays on screen, streams a running conversation live, and lets you pause or
309
- resume it and send new messages from the browser.
310
+ resume it and send new messages from the browser. Conversation text is rendered
311
+ as markdown — headings, lists (including tasks and nesting), tables, block
312
+ quotes, fenced code, emphasis, links and images — while raw HTML stays inert and
313
+ only safe link/image schemes become clickable.
314
+
315
+ **New conversation** in the sidebar starts a chat that has no transcript yet.
316
+ Its dialog chooses the working directory and the provider, model and thinking
317
+ level for the first turn. **Browse…** opens the server's folder picker, where you
318
+ can walk the filesystem, create a folder, and use it as the conversation's
319
+ working directory. Opened folders are remembered under **Working directories**
320
+ (the folder button above the list), which scopes the sidebar to that project and
321
+ lets you start a new chat there; you can always return to **All directories**.
310
322
 
311
323
  A saved transcript opens read-only, because nothing is running for it. Press
312
324
  **Continue here** (or simply type in the composer) and the server resumes it
313
325
  in-process — same model, working directory and token counters — so you can keep
314
326
  chatting from the browser with no terminal attached. Turns are written back to
315
- the same session file.
327
+ the same session file. **Clear** starts a fresh conversation in the same folder
328
+ (the previous transcript stays saved), **Delete** removes a saved transcript,
329
+ and **Other agents** lists read-only sessions that Claude Code, Codex or
330
+ OpenCode recorded in that folder.
316
331
 
317
332
  Use the selectors above a conversation to change its provider, model or
318
333
  intensity, then press **Apply to conversation** (pause an active turn first).
319
334
  The **Settings** (gear) button in the sidebar edits defaults for new conversations,
320
- context compression and approval settings. Conversation choices are saved with
321
- the transcript and do not overwrite the defaults. Configured models and keys
322
- are shared with the terminal; keys stay on the server.
335
+ the interface language, plan-first turns, the default approval mode, context
336
+ compression and approval settings. Conversation choices are saved with the
337
+ transcript and do not overwrite the defaults. Configured models and keys are
338
+ shared with the terminal; keys stay on the server.
339
+
340
+ The interface language can be switched in the sidebar footer or in
341
+ **Settings → Interface**: English and 简体中文 are supported. The change applies
342
+ immediately without a reload, survives restarts in `config.json`, and is shared
343
+ by every browser. The terminal UI itself is not translated.
344
+
345
+ The `ⓘ` button in a conversation header opens the same plan/token card as
346
+ `Ctrl+P` in the terminal: the steps of a planned turn with their live statuses,
347
+ and the prompt/completion/total token counts when no plan is running.
323
348
 
324
349
  Context auto-compression starts at 80% of the current model's context window.
325
350
  Models default to 1,000,000 tokens (compression at 800,000). Adjust the model
@@ -450,6 +475,9 @@ node scripts/test-tools.mjs # verify tool-output folding
450
475
  node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
451
476
  node scripts/test-web-settings.mjs # verify model switching, persistence, browser controls and defaults
452
477
  node scripts/test-web-settings-http.mjs # verify authenticated settings and model changes over HTTP
478
+ node scripts/test-web-markdown.mjs # verify the browser markdown renderer and its XSS boundaries
479
+ node scripts/test-web-i18n.mjs # verify every console label has a Chinese translation and placeholders survive
480
+ node scripts/test-web-workspaces.mjs # verify folder browsing/creation, new conversations, clear/delete and other-agent history
453
481
  ```
454
482
 
455
483
  ### Rendering model
package/dist/config.js CHANGED
@@ -63,6 +63,12 @@ export const DEFAULT_MODELS = {
63
63
  },
64
64
  ],
65
65
  };
66
+ /** Languages the browser console can render its own interface in. */
67
+ export const WEB_LANGUAGES = ["en", "zh"];
68
+ /** Unknown or missing values fall back to English rather than breaking the UI. */
69
+ export function normalizeWebLanguage(value) {
70
+ return typeof value === "string" && value.trim().toLowerCase().startsWith("zh") ? "zh" : "en";
71
+ }
66
72
  export const DEFAULT_CONFIG = {
67
73
  language: "en",
68
74
  theme: "default",
@@ -83,6 +89,9 @@ export const DEFAULT_CONFIG = {
83
89
  // Mount the browser UI alongside the terminal UI unless it is turned off
84
90
  // here or on the command line.
85
91
  web_console: true,
92
+ // Working directories opened from the browser, newest first. They remain
93
+ // listed even when they hold no conversations yet.
94
+ web_workspaces: [],
86
95
  // Initialized as a salted hash of the default Web password on first use.
87
96
  web_password_hash: "",
88
97
  };
@@ -327,6 +327,45 @@ export class ChatScreen extends Screen {
327
327
  this.app.render();
328
328
  this.publishLive();
329
329
  }
330
+ /**
331
+ * Start a fresh conversation in the same working directory. Used by Ctrl+L
332
+ * and by the browser's Clear button, so both screens behave identically. The
333
+ * previous transcript is saved first and is never overwritten.
334
+ */
335
+ liveClear() {
336
+ this.save();
337
+ if (this.saveError) {
338
+ this.app.render();
339
+ return;
340
+ }
341
+ this.turnToken++;
342
+ if (this.pendingApproval)
343
+ this.settleApproval(false);
344
+ this.busy = false;
345
+ this.paused = false;
346
+ this.queued = null;
347
+ this.compressing = false;
348
+ this.retryNotice = null;
349
+ this.app.state.isThinking = false;
350
+ this.lastPromptTokens = 0;
351
+ this.sessionId = null;
352
+ this.context = null;
353
+ this.expandedContext = false;
354
+ this.plan = null;
355
+ this.planning = false;
356
+ this.subagent = null;
357
+ const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
358
+ this.messages = [{ role: "system", content: sysText }];
359
+ this.expandedTools.clear();
360
+ this.finishedTurns.clear();
361
+ this.expandedTurns.clear();
362
+ this.activeTurnStart = null;
363
+ this.streamingContent = null;
364
+ this.scrollTop = 0;
365
+ this.followBottom = true;
366
+ this.publishLive();
367
+ this.app.render();
368
+ }
330
369
  liveSend(text) {
331
370
  if (!text.trim())
332
371
  return;
@@ -494,36 +533,7 @@ export class ChatScreen extends Screen {
494
533
  return;
495
534
  }
496
535
  if (key.ctrl && key.name === "l") {
497
- // Clearing starts a fresh conversation; do not overwrite the old history.
498
- this.save();
499
- if (this.saveError) {
500
- this.app.render();
501
- return;
502
- }
503
- this.turnToken++;
504
- this.busy = false;
505
- this.paused = false;
506
- this.queued = null;
507
- this.compressing = false;
508
- this.app.state.isThinking = false;
509
- this.lastPromptTokens = 0;
510
- this.sessionId = null;
511
- this.context = null;
512
- this.expandedContext = false;
513
- this.plan = null;
514
- this.planning = false;
515
- this.subagent = null;
516
- const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
517
- this.messages = [{ role: "system", content: sysText }];
518
- this.expandedTools.clear();
519
- this.finishedTurns.clear();
520
- this.expandedTurns.clear();
521
- this.activeTurnStart = null;
522
- this.streamingContent = null;
523
- this.scrollTop = 0;
524
- this.followBottom = true;
525
- this.publishLive();
526
- this.app.render();
536
+ this.liveClear();
527
537
  return;
528
538
  }
529
539
  if (key.ctrl && key.name === "u") {
package/dist/web/hub.js CHANGED
@@ -175,6 +175,15 @@ class LiveHub {
175
175
  this.notify(key);
176
176
  return true;
177
177
  }
178
+ /** Empty a live conversation in place, keeping its key and working folder. */
179
+ clear(key) {
180
+ const entry = this.entries.get(key);
181
+ if (!entry?.agent.liveClear)
182
+ return false;
183
+ entry.agent.liveClear();
184
+ this.notify(key);
185
+ return true;
186
+ }
178
187
  }
179
188
  /** Process-wide singleton shared by the TUI and the web server. */
180
189
  export const hub = new LiveHub();
@@ -16,13 +16,14 @@ import http from "node:http";
16
16
  import os from "node:os";
17
17
  import path from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
- import { SESSION_DIR, listSessions, resolveModelSelection } from "../config.js";
19
+ import { SESSION_DIR, deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, } from "../config.js";
20
20
  import { getWebSettings, updateWebSettings } from "./settings.js";
21
21
  import { WebAuth } from "./auth.js";
22
- import { normalizeCwd } from "../cwd.js";
22
+ import { normalizeCwd, sameCwd } from "../cwd.js";
23
23
  import { hub } from "./hub.js";
24
24
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { resumeSession } from "./session.js";
25
+ import { createWebSession, resumeSession } from "./session.js";
26
+ import { listAgentSessions, loadAgentMessages } from "../agents.js";
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
  // dist/web -> package root (mirrors the same trick used in config.ts).
28
29
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -132,6 +133,194 @@ function loadSessionSafe(id) {
132
133
  return null;
133
134
  }
134
135
  }
136
+ // ------------------------------------------------------- working directories
137
+ //
138
+ // The browser console can open any folder on this machine, which is exactly
139
+ // what the terminal can do by being launched from that folder. Every route
140
+ // below still sits behind the login cookie: browsing the filesystem is only as
141
+ // exposed as the authenticated console itself.
142
+ class ApiError extends Error {
143
+ status;
144
+ code;
145
+ constructor(status, code, message) {
146
+ super(message);
147
+ this.status = status;
148
+ this.code = code;
149
+ }
150
+ }
151
+ function sendApiError(res, error) {
152
+ if (error instanceof ApiError) {
153
+ sendJson(res, error.status, { error: error.message, code: error.code });
154
+ return;
155
+ }
156
+ const message = error instanceof Error ? error.message : "invalid request";
157
+ sendJson(res, 400, { error: message });
158
+ }
159
+ const MAX_RECENT_WORKSPACES = 12;
160
+ const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
161
+ const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
162
+ /** Expand `~` and require an absolute path; used by every folder route. */
163
+ function absolutePath(raw) {
164
+ if (typeof raw !== "string" || !raw.trim()) {
165
+ throw new ApiError(400, "working directory is required", "working directory is required");
166
+ }
167
+ let value = raw.trim();
168
+ if (value === "~")
169
+ value = os.homedir();
170
+ else if (/^~[\\/]/.test(value))
171
+ value = path.join(os.homedir(), value.slice(2));
172
+ if (!path.isAbsolute(value)) {
173
+ throw new ApiError(400, "working directory must be an absolute path", "working directory must be an absolute path");
174
+ }
175
+ return path.resolve(value);
176
+ }
177
+ function existingDirectory(raw) {
178
+ const dir = absolutePath(raw);
179
+ let stat;
180
+ try {
181
+ stat = fs.statSync(dir);
182
+ }
183
+ catch {
184
+ throw new ApiError(400, "working directory does not exist", "working directory does not exist");
185
+ }
186
+ if (!stat.isDirectory()) {
187
+ throw new ApiError(400, "working directory is not a folder", "working directory is not a folder");
188
+ }
189
+ return dir;
190
+ }
191
+ let cachedRoots = null;
192
+ /** Filesystem roots worth offering as shortcuts (every existing drive on Windows). */
193
+ function filesystemRoots() {
194
+ if (cachedRoots)
195
+ return cachedRoots;
196
+ if (process.platform !== "win32") {
197
+ cachedRoots = ["/"];
198
+ return cachedRoots;
199
+ }
200
+ const roots = [];
201
+ for (let code = 65; code <= 90; code += 1) {
202
+ const root = `${String.fromCharCode(code)}:\\`;
203
+ try {
204
+ if (fs.statSync(root).isDirectory())
205
+ roots.push(root);
206
+ }
207
+ catch {
208
+ // A drive letter that does not exist is simply not offered.
209
+ }
210
+ }
211
+ cachedRoots = roots.length ? roots : [path.parse(process.cwd()).root];
212
+ return cachedRoots;
213
+ }
214
+ /** One directory listing for the folder picker. Files are hidden: the picker chooses directories. */
215
+ function readFolder(raw) {
216
+ const dir = existingDirectory(raw);
217
+ let entries;
218
+ try {
219
+ entries = fs.readdirSync(dir, { withFileTypes: true });
220
+ }
221
+ catch (err) {
222
+ throw new ApiError(403, "cannot read this folder", err instanceof Error ? err.message : "cannot read this folder");
223
+ }
224
+ const folders = entries
225
+ .filter((entry) => entry.isDirectory())
226
+ .map((entry) => ({
227
+ name: entry.name,
228
+ path: path.join(dir, entry.name),
229
+ hidden: entry.name.startsWith("."),
230
+ }))
231
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
232
+ const parent = path.dirname(dir);
233
+ return {
234
+ path: dir,
235
+ name: path.basename(dir) || dir,
236
+ parent: parent === dir ? null : parent,
237
+ separator: path.sep,
238
+ home: os.homedir(),
239
+ cwd: process.cwd(),
240
+ roots: filesystemRoots(),
241
+ entries: folders,
242
+ };
243
+ }
244
+ /** Recent working directories: explicitly opened ones first, then saved-session folders. */
245
+ function recentWorkspacePaths() {
246
+ const config = loadConfig();
247
+ const stored = Array.isArray(config.web_workspaces)
248
+ ? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
249
+ : [];
250
+ const saved = listSessions().map((session) => session.cwd).filter(Boolean);
251
+ const seen = new Set();
252
+ const out = [];
253
+ for (const dir of [...stored, ...saved, process.cwd()]) {
254
+ const key = normalizeCwd(dir);
255
+ if (!key || seen.has(key))
256
+ continue;
257
+ seen.add(key);
258
+ out.push(dir);
259
+ if (out.length >= MAX_RECENT_WORKSPACES)
260
+ break;
261
+ }
262
+ return out;
263
+ }
264
+ function workspaceList() {
265
+ const sessions = listSessions();
266
+ return recentWorkspacePaths().map((dir) => {
267
+ let exists = false;
268
+ try {
269
+ exists = fs.statSync(dir).isDirectory();
270
+ }
271
+ catch {
272
+ // A folder that disappeared stays listed, greyed out, so it can be removed by choosing another.
273
+ }
274
+ return {
275
+ path: dir,
276
+ name: path.basename(dir) || dir,
277
+ sessions: sessions.filter((session) => sameCwd(session.cwd, dir)).length,
278
+ exists,
279
+ };
280
+ });
281
+ }
282
+ /** Best-effort persistence: failing to remember a folder must not fail the conversation. */
283
+ function recordWorkspace(dir) {
284
+ try {
285
+ const config = loadConfig();
286
+ const stored = Array.isArray(config.web_workspaces)
287
+ ? config.web_workspaces.filter((entry) => typeof entry === "string")
288
+ : [];
289
+ config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
290
+ .slice(0, MAX_RECENT_WORKSPACES);
291
+ saveConfig(config);
292
+ }
293
+ catch {
294
+ // The picked folder still works for this conversation.
295
+ }
296
+ }
297
+ /** Create one folder inside an existing parent. */
298
+ function createFolder(raw) {
299
+ const target = absolutePath(raw);
300
+ if (target === path.parse(target).root) {
301
+ throw new ApiError(400, "cannot create a folder here", "cannot create a folder here");
302
+ }
303
+ const parent = path.dirname(target);
304
+ if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) {
305
+ throw new ApiError(400, "parent folder does not exist", "parent folder does not exist");
306
+ }
307
+ if (fs.existsSync(target)) {
308
+ throw new ApiError(409, "folder already exists", "folder already exists");
309
+ }
310
+ try {
311
+ fs.mkdirSync(target);
312
+ }
313
+ catch (err) {
314
+ throw new ApiError(400, "cannot create a folder here", err instanceof Error ? err.message : "cannot create a folder here");
315
+ }
316
+ return target;
317
+ }
318
+ function isAgentId(value) {
319
+ return AGENT_IDS.has(value);
320
+ }
321
+ function safeAgentSessionId(id) {
322
+ return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
323
+ }
135
324
  /** Build the sidebar payload: every session grouped by working directory. */
136
325
  function buildOverview() {
137
326
  const sessions = listSessions();
@@ -178,7 +367,9 @@ function buildOverview() {
178
367
  provider: live.provider,
179
368
  intensity: live.intensity,
180
369
  cwd: live.cwd,
181
- title: live.title || "New conversation",
370
+ // An unnamed conversation keeps an empty title: the browser translates
371
+ // its own fallback, and the terminal/server never impose one language.
372
+ title: live.title || "",
182
373
  message_count: 0,
183
374
  live: true,
184
375
  liveKey: live.key,
@@ -424,6 +615,155 @@ async function handleApi(req, res, url, auth) {
424
615
  sendJson(res, 200, { sessions: listSessions() });
425
616
  return true;
426
617
  }
618
+ if (pathname === "/api/workspaces") {
619
+ if (req.method === "GET") {
620
+ sendJson(res, 200, {
621
+ cwd: process.cwd(),
622
+ home: os.homedir(),
623
+ workspaces: workspaceList(),
624
+ });
625
+ return true;
626
+ }
627
+ if (req.method === "POST") {
628
+ if (!isSameOrigin(req)) {
629
+ sendJson(res, 403, { error: "cross-origin request rejected" });
630
+ return true;
631
+ }
632
+ try {
633
+ const body = await readJsonBody(req);
634
+ const dir = existingDirectory(body?.path);
635
+ recordWorkspace(dir);
636
+ sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
637
+ }
638
+ catch (err) {
639
+ sendApiError(res, err);
640
+ }
641
+ return true;
642
+ }
643
+ sendJson(res, 405, { error: "method not allowed" });
644
+ return true;
645
+ }
646
+ if (pathname === "/api/fs") {
647
+ if (req.method !== "GET") {
648
+ sendJson(res, 405, { error: "method not allowed" });
649
+ return true;
650
+ }
651
+ try {
652
+ sendJson(res, 200, readFolder(url.searchParams.get("path") ?? process.cwd()));
653
+ }
654
+ catch (err) {
655
+ sendApiError(res, err);
656
+ }
657
+ return true;
658
+ }
659
+ if (pathname === "/api/fs/mkdir") {
660
+ if (req.method !== "POST") {
661
+ sendJson(res, 405, { error: "method not allowed" });
662
+ return true;
663
+ }
664
+ if (!isSameOrigin(req)) {
665
+ sendJson(res, 403, { error: "cross-origin request rejected" });
666
+ return true;
667
+ }
668
+ try {
669
+ const body = await readJsonBody(req);
670
+ const created = createFolder(body?.path);
671
+ sendJson(res, 200, { ok: true, path: created });
672
+ }
673
+ catch (err) {
674
+ sendApiError(res, err);
675
+ }
676
+ return true;
677
+ }
678
+ // Start a brand-new conversation in a working directory chosen by the
679
+ // browser. Unlike `/continue`, there is no transcript to load first.
680
+ if (pathname === "/api/conversations") {
681
+ if (req.method !== "POST") {
682
+ sendJson(res, 405, { error: "method not allowed" });
683
+ return true;
684
+ }
685
+ if (!isSameOrigin(req)) {
686
+ sendJson(res, 403, { error: "cross-origin request rejected" });
687
+ return true;
688
+ }
689
+ try {
690
+ const body = await readJsonBody(req);
691
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
692
+ throw new ApiError(400, "invalid request", "invalid request");
693
+ }
694
+ const input = body;
695
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
696
+ const requested = input.selection === undefined
697
+ ? getWebSettings().selection
698
+ : input.selection;
699
+ const selection = resolveModelSelection(requested, false);
700
+ let approvalMode;
701
+ if (input.approval_mode !== undefined) {
702
+ if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
703
+ throw new ApiError(400, "invalid approval mode", "invalid approval mode");
704
+ }
705
+ approvalMode = normalizeApprovalMode(input.approval_mode);
706
+ }
707
+ const session = createWebSession(cwd, selection, approvalMode);
708
+ const key = session.start();
709
+ webSessions.set(key, session);
710
+ recordWorkspace(cwd);
711
+ sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
712
+ }
713
+ catch (err) {
714
+ sendApiError(res, err);
715
+ }
716
+ return true;
717
+ }
718
+ const deleteMatch = /^\/api\/session\/([^/]+)\/delete$/.exec(pathname);
719
+ if (req.method === "POST" && deleteMatch) {
720
+ if (!isSameOrigin(req)) {
721
+ sendJson(res, 403, { error: "cross-origin request rejected" });
722
+ return true;
723
+ }
724
+ const id = decodeURIComponent(deleteMatch[1]);
725
+ if (!isSafeSessionId(id)) {
726
+ sendJson(res, 400, { error: "invalid session id" });
727
+ return true;
728
+ }
729
+ if (hub.list().some((entry) => entry.sessionId === id)) {
730
+ sendJson(res, 409, { error: "cannot delete a running conversation" });
731
+ return true;
732
+ }
733
+ if (!deleteSession(id)) {
734
+ sendJson(res, 404, { error: "session not found" });
735
+ return true;
736
+ }
737
+ sendJson(res, 200, { ok: true, deleted: true });
738
+ return true;
739
+ }
740
+ // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
741
+ const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
742
+ if (req.method === "GET" && agentSessionsMatch) {
743
+ const agent = decodeURIComponent(agentSessionsMatch[1]);
744
+ if (!isAgentId(agent)) {
745
+ sendJson(res, 400, { error: "unknown agent" });
746
+ return true;
747
+ }
748
+ const cwd = url.searchParams.get("cwd") || process.cwd();
749
+ sendJson(res, 200, { agent, cwd, sessions: listAgentSessions(agent, cwd) });
750
+ return true;
751
+ }
752
+ const agentSessionMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname);
753
+ if (req.method === "GET" && agentSessionMatch) {
754
+ const agent = decodeURIComponent(agentSessionMatch[1]);
755
+ const id = decodeURIComponent(agentSessionMatch[2]);
756
+ if (!isAgentId(agent)) {
757
+ sendJson(res, 400, { error: "unknown agent" });
758
+ return true;
759
+ }
760
+ if (!safeAgentSessionId(id)) {
761
+ sendJson(res, 400, { error: "invalid session id" });
762
+ return true;
763
+ }
764
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
765
+ return true;
766
+ }
427
767
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
428
768
  if (req.method === "GET" && sessionMatch) {
429
769
  const id = decodeURIComponent(sessionMatch[1]);
@@ -499,7 +839,7 @@ async function handleApi(req, res, url, auth) {
499
839
  streamLive(decodeURIComponent(streamMatch[1]), req, res);
500
840
  return true;
501
841
  }
502
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode))?$/.exec(pathname);
842
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
503
843
  if (liveMatch) {
504
844
  const key = decodeURIComponent(liveMatch[1]);
505
845
  const action = liveMatch[2];
@@ -593,6 +933,14 @@ async function handleApi(req, res, url, auth) {
593
933
  sendJson(res, 200, { ok: true, closed: true });
594
934
  return true;
595
935
  }
936
+ if (action === "clear") {
937
+ if (!hub.clear(key)) {
938
+ sendJson(res, 409, { error: "this session cannot be cleared" });
939
+ return true;
940
+ }
941
+ sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
942
+ return true;
943
+ }
596
944
  // action === "message"
597
945
  let body;
598
946
  try {