@xingjisoft/neutrino 0.1.4 → 0.1.5
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 +398 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +85 -3
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +533 -45
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +31 -6
- package/dist/terminal/restore.js +147 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { flushSessionCheckpoints } from "../persistence.js";
|
|
6
|
+
const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
|
|
7
|
+
/**
|
|
8
|
+
* Escape sequences that undo everything the TUI switches on.
|
|
9
|
+
*
|
|
10
|
+
* Mouse tracking is disabled before the alternate screen is left, and the
|
|
11
|
+
* cursor is restored before the screen swap, so the shell never inherits a
|
|
12
|
+
* half-configured terminal. Order matters: `?1049l` must come last or the
|
|
13
|
+
* remaining writes would land on the main screen.
|
|
14
|
+
*/
|
|
15
|
+
export const TUI_TEARDOWN = "\x1b[?2004l" + // bracketed paste off
|
|
16
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l" + // mouse tracking off
|
|
17
|
+
"\x1b[?25h" + // cursor visible
|
|
18
|
+
"\x1b[0m" + // default attributes
|
|
19
|
+
"\x1b[?1049l"; // back to the main screen
|
|
20
|
+
/** Whether a TUI has claimed the terminal and its state still needs undoing. */
|
|
21
|
+
let active = false;
|
|
22
|
+
let restored = false;
|
|
23
|
+
let installed = false;
|
|
24
|
+
/** Called once the renderer has entered the alternate screen and raw mode. */
|
|
25
|
+
export function markTuiActive() {
|
|
26
|
+
// Unconditional: only the renderer calls this, and the renderer is only ever
|
|
27
|
+
// constructed once the app has decided it owns a real terminal.
|
|
28
|
+
active = true;
|
|
29
|
+
restored = false;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Puts the terminal back into a usable state, synchronously and at most once.
|
|
33
|
+
*
|
|
34
|
+
* Everything here has to work while the process is dying: no promises, no
|
|
35
|
+
* asynchronous writes, and every step individually guarded so one failure
|
|
36
|
+
* cannot skip the rest (a broken `setRawMode` must not leave the cursor
|
|
37
|
+
* hidden, for example).
|
|
38
|
+
*/
|
|
39
|
+
export function restoreTerminalSync() {
|
|
40
|
+
if (!active || restored)
|
|
41
|
+
return false;
|
|
42
|
+
let outputRestored = false;
|
|
43
|
+
let inputRestored = false;
|
|
44
|
+
try {
|
|
45
|
+
// fd 1 write is the only one guaranteed to be flushed by a dying process;
|
|
46
|
+
// `process.stdout.write` may queue the bytes and lose them at exit.
|
|
47
|
+
const bytes = Buffer.from(TUI_TEARDOWN);
|
|
48
|
+
let offset = 0;
|
|
49
|
+
while (offset < bytes.length) {
|
|
50
|
+
const written = fs.writeSync(1, bytes, offset, bytes.length - offset);
|
|
51
|
+
if (written === 0)
|
|
52
|
+
throw new Error("terminal write made no progress");
|
|
53
|
+
offset += written;
|
|
54
|
+
}
|
|
55
|
+
outputRestored = true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
try {
|
|
59
|
+
process.stdout.write(TUI_TEARDOWN);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// The terminal is gone; there is nothing left to restore.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
if (process.stdin.isTTY && process.stdin.isRaw)
|
|
67
|
+
process.stdin.setRawMode(false);
|
|
68
|
+
inputRestored = true;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// stdin already closed, or not a TTY we can reconfigure.
|
|
72
|
+
}
|
|
73
|
+
// A queued or failed write must not disarm the synchronous exit retry.
|
|
74
|
+
restored = outputRestored && inputRestored;
|
|
75
|
+
return restored;
|
|
76
|
+
}
|
|
77
|
+
/** Appends a crash entry to `~/.neutrino/crash.log` and returns the path. */
|
|
78
|
+
export function writeCrashLog(kind, err) {
|
|
79
|
+
const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
|
|
80
|
+
const entry = `[${new Date().toISOString()}] ${kind} pid=${process.pid}\n${detail}\n\n`;
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
83
|
+
const file = path.join(NEUTRINO_DIR, "crash.log");
|
|
84
|
+
fs.appendFileSync(file, entry, "utf8");
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Installs process-wide handlers that restore the terminal before the process
|
|
93
|
+
* dies.
|
|
94
|
+
*
|
|
95
|
+
* Without this, any throw that escapes a timer, a stream callback or a floating
|
|
96
|
+
* promise kills the process with raw mode still on and the alternate screen
|
|
97
|
+
* still active. The visible result is a terminal that works but spews
|
|
98
|
+
* `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
|
|
99
|
+
* receives the mouse events the TUI asked the terminal to send.
|
|
100
|
+
*/
|
|
101
|
+
export function installCrashGuard() {
|
|
102
|
+
if (installed)
|
|
103
|
+
return;
|
|
104
|
+
installed = true;
|
|
105
|
+
let flushed = false;
|
|
106
|
+
const finalize = () => {
|
|
107
|
+
restoreTerminalSync();
|
|
108
|
+
if (flushed)
|
|
109
|
+
return true;
|
|
110
|
+
flushed = true;
|
|
111
|
+
return flushSessionCheckpoints();
|
|
112
|
+
};
|
|
113
|
+
process.on("exit", () => {
|
|
114
|
+
if (!finalize()) {
|
|
115
|
+
try {
|
|
116
|
+
fs.writeSync(2, "\nneutrino: some conversation changes could not be saved; check disk access and crash.log.\n");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The crash log is the remaining record if stderr is unavailable.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// These signals otherwise bypass Node's exit hook, including Ctrl+Break on
|
|
124
|
+
// Windows. SIGINT/SIGTERM are handled by App.quit (or the web server).
|
|
125
|
+
process.on("SIGHUP", () => process.exit(129));
|
|
126
|
+
process.on("SIGBREAK", () => process.exit(149));
|
|
127
|
+
const fatal = (kind, err) => {
|
|
128
|
+
// Restore first: the terminal must be usable even if logging itself fails.
|
|
129
|
+
const saved = finalize();
|
|
130
|
+
const file = writeCrashLog(kind, err);
|
|
131
|
+
const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
|
|
132
|
+
try {
|
|
133
|
+
const tail = file ? `\nFull log: ${file}` : "";
|
|
134
|
+
fs.writeSync(2, `\nneutrino stopped: ${kind}\n\n${detail}\n\n` +
|
|
135
|
+
(saved
|
|
136
|
+
? "Check history for the latest saved conversation snapshots."
|
|
137
|
+
: "Some conversation changes could not be saved; check disk access and crash.log.") +
|
|
138
|
+
`${tail}\n`);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// stderr is gone too; the crash log is the only record.
|
|
142
|
+
}
|
|
143
|
+
process.exit(1);
|
|
144
|
+
};
|
|
145
|
+
process.on("uncaughtException", (err) => fatal("uncaught exception", err));
|
|
146
|
+
process.on("unhandledRejection", (err) => fatal("unhandled rejection", err));
|
|
147
|
+
}
|
package/dist/ui.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { C } from "./colors.js";
|
|
2
2
|
import { displayWidth, truncateByWidth, wcwidth } from "./width.js";
|
|
3
|
+
/** One terminal row; shared geometry keeps rendering and hit testing aligned. */
|
|
4
|
+
export function approvalToggle(width, y, mode) {
|
|
5
|
+
const label = mode === "auto" ? "[ Auto Approval ]" : "[ Request Approval ]";
|
|
6
|
+
const text = truncateByWidth(label, Math.max(0, width - 2));
|
|
7
|
+
return { x: Math.max(0, Math.floor((width - displayWidth(text)) / 2)), y, w: displayWidth(text), h: 1, text };
|
|
8
|
+
}
|
|
9
|
+
export function drawApprovalToggle(buf, y, mode, focused = false) {
|
|
10
|
+
if (y < 1 || y >= buf.height)
|
|
11
|
+
return;
|
|
12
|
+
const button = approvalToggle(buf.width, y, mode);
|
|
13
|
+
buf.writeText(button.x, y, button.text, {
|
|
14
|
+
fg: focused ? C.black : mode === "auto" ? C.green : C.accent,
|
|
15
|
+
bg: focused ? C.accent : C.highlightBg,
|
|
16
|
+
bold: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
3
19
|
function chunkByWidth(text, width) {
|
|
4
20
|
const chunks = [];
|
|
5
21
|
let cur = "";
|
package/dist/web/auth.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
4
|
+
export const DEFAULT_WEB_PASSWORD = "114514";
|
|
5
|
+
const COOKIE = "neutrino_session";
|
|
6
|
+
const MAX_AGE = 365 * 24 * 60 * 60;
|
|
7
|
+
const scrypt = promisify(crypto.scrypt);
|
|
8
|
+
export function validateWebPassword(password) {
|
|
9
|
+
if (typeof password !== "string" || !password.trim() || password.length > 256) {
|
|
10
|
+
throw new Error("Password must contain 1–256 characters and cannot be blank.");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Only a salted password hash is persisted; settings never return it. */
|
|
14
|
+
export function hashWebPassword(password) {
|
|
15
|
+
validateWebPassword(password);
|
|
16
|
+
const salt = crypto.randomBytes(16).toString("hex");
|
|
17
|
+
return `scrypt:${salt}:${crypto.scryptSync(password, salt, 64).toString("hex")}`;
|
|
18
|
+
}
|
|
19
|
+
function passwordHash() {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
if (typeof config.web_password_hash === "string" && config.web_password_hash)
|
|
22
|
+
return config.web_password_hash;
|
|
23
|
+
const hash = hashWebPassword(DEFAULT_WEB_PASSWORD);
|
|
24
|
+
config.web_password_hash = hash;
|
|
25
|
+
saveConfig(config);
|
|
26
|
+
return hash;
|
|
27
|
+
}
|
|
28
|
+
function signature(value, hash) {
|
|
29
|
+
return crypto.createHmac("sha256", hash).update(value).digest("base64url");
|
|
30
|
+
}
|
|
31
|
+
function matches(a, b) {
|
|
32
|
+
const left = Buffer.from(a);
|
|
33
|
+
const right = Buffer.from(b);
|
|
34
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
35
|
+
}
|
|
36
|
+
/** Signed cookies survive restarts, but a password change invalidates all of them. */
|
|
37
|
+
export class WebAuth {
|
|
38
|
+
enabled;
|
|
39
|
+
constructor(enabled) {
|
|
40
|
+
this.enabled = enabled;
|
|
41
|
+
if (enabled)
|
|
42
|
+
passwordHash();
|
|
43
|
+
}
|
|
44
|
+
authenticated(req) {
|
|
45
|
+
if (!this.enabled)
|
|
46
|
+
return true;
|
|
47
|
+
const value = req.headers.cookie?.split(";").map(part => part.trim())
|
|
48
|
+
.find(part => part.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
|
49
|
+
if (!value)
|
|
50
|
+
return false;
|
|
51
|
+
const [expires, nonce, mac, extra] = value.split(".");
|
|
52
|
+
if (extra !== undefined || !/^\d+$/.test(expires) || !nonce || !mac || Number(expires) <= Date.now())
|
|
53
|
+
return false;
|
|
54
|
+
return matches(mac, signature(`${expires}.${nonce}`, passwordHash()));
|
|
55
|
+
}
|
|
56
|
+
async login(password) {
|
|
57
|
+
if (!this.enabled)
|
|
58
|
+
return true;
|
|
59
|
+
try {
|
|
60
|
+
validateWebPassword(password);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const hash = passwordHash();
|
|
66
|
+
const [kind, salt, expected] = hash.split(":");
|
|
67
|
+
if (kind !== "scrypt" || !salt || !expected)
|
|
68
|
+
return false;
|
|
69
|
+
const derived = await scrypt(password, salt, 64);
|
|
70
|
+
// A concurrent settings change must not issue a session for the old password.
|
|
71
|
+
return hash === passwordHash() && matches(derived.toString("hex"), expected);
|
|
72
|
+
}
|
|
73
|
+
remember(res) {
|
|
74
|
+
if (!this.enabled)
|
|
75
|
+
return;
|
|
76
|
+
const value = `${Date.now() + MAX_AGE * 1000}.${crypto.randomBytes(24).toString("base64url")}`;
|
|
77
|
+
res.setHeader("Set-Cookie", `${COOKIE}=${value}.${signature(value, passwordHash())}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${MAX_AGE}`);
|
|
78
|
+
}
|
|
79
|
+
forget(res) {
|
|
80
|
+
res.setHeader("Set-Cookie", `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/web/hub.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live-session hub.
|
|
3
|
+
*
|
|
4
|
+
* The terminal UI is the source of truth for a conversation: while a
|
|
5
|
+
* {@link ../screens/chat.js ChatScreen} is open it owns the message list, the
|
|
6
|
+
* streaming buffer and the busy/paused flags. The web server, however, must be
|
|
7
|
+
* able to (a) show *every* conversation live and (b) drive the running one
|
|
8
|
+
* (pause, resume, send). This hub is the bridge between the two.
|
|
9
|
+
*
|
|
10
|
+
* A screen registers itself once it is activated and unregisters when it is
|
|
11
|
+
* popped. Every time the screen changes something meaningful it calls
|
|
12
|
+
* {@link LiveHub.notify}, which coalesces rapid-fire updates (one per streamed
|
|
13
|
+
* token) and pushes a snapshot to any connected browser.
|
|
14
|
+
*/
|
|
15
|
+
export function liveApprovalDetails(tool, args, risk, cwd) {
|
|
16
|
+
return { tool, level: risk.level, arguments: args, cwd, summary: risk.summary, reasons: risk.reasons };
|
|
17
|
+
}
|
|
18
|
+
// Streaming emits tokens very quickly; coalescing to ~12fps keeps the browser
|
|
19
|
+
// smooth without serialising the whole conversation on every token.
|
|
20
|
+
const FLUSH_MS = 80;
|
|
21
|
+
class LiveHub {
|
|
22
|
+
entries = new Map();
|
|
23
|
+
seq = 0;
|
|
24
|
+
/** Register a screen. Returns the stable key used to address it over HTTP. */
|
|
25
|
+
register(agent) {
|
|
26
|
+
for (const entry of this.entries.values()) {
|
|
27
|
+
if (entry.agent === agent)
|
|
28
|
+
return entry.key;
|
|
29
|
+
}
|
|
30
|
+
const key = `L${++this.seq}`;
|
|
31
|
+
this.entries.set(key, {
|
|
32
|
+
key,
|
|
33
|
+
agent,
|
|
34
|
+
cwd: agent.liveCwd(),
|
|
35
|
+
listeners: new Set(),
|
|
36
|
+
timer: null,
|
|
37
|
+
lastSent: "",
|
|
38
|
+
});
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
unregister(key) {
|
|
42
|
+
if (!key)
|
|
43
|
+
return;
|
|
44
|
+
const entry = this.entries.get(key);
|
|
45
|
+
if (!entry)
|
|
46
|
+
return;
|
|
47
|
+
if (entry.timer)
|
|
48
|
+
clearTimeout(entry.timer);
|
|
49
|
+
this.entries.delete(key);
|
|
50
|
+
}
|
|
51
|
+
has(key) {
|
|
52
|
+
return this.entries.has(key);
|
|
53
|
+
}
|
|
54
|
+
/** All live sessions, newest registration last. */
|
|
55
|
+
list() {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const entry of this.entries.values()) {
|
|
58
|
+
out.push({ ...entry.agent.liveMeta(), key: entry.key, cwd: entry.cwd });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** One-off snapshot, or null when the key is unknown. */
|
|
63
|
+
snapshot(key) {
|
|
64
|
+
const entry = this.entries.get(key);
|
|
65
|
+
return entry ? entry.agent.liveSnapshot(key) : null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Subscribe to a live session. The current snapshot is delivered immediately
|
|
69
|
+
* so a freshly-connected browser never has to wait for the next change.
|
|
70
|
+
*/
|
|
71
|
+
subscribe(key, fn) {
|
|
72
|
+
const entry = this.entries.get(key);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return null;
|
|
75
|
+
entry.listeners.add(fn);
|
|
76
|
+
try {
|
|
77
|
+
fn(entry.agent.liveSnapshot(key));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// A broken listener must not break registration.
|
|
81
|
+
}
|
|
82
|
+
return () => {
|
|
83
|
+
entry.listeners.delete(fn);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Mark a session as changed. Snapshots are only produced when somebody is
|
|
88
|
+
* listening, and duplicate snapshots (e.g. a cursor blink) are dropped.
|
|
89
|
+
*/
|
|
90
|
+
notify(key) {
|
|
91
|
+
if (!key)
|
|
92
|
+
return;
|
|
93
|
+
const entry = this.entries.get(key);
|
|
94
|
+
if (!entry || entry.listeners.size === 0 || entry.timer)
|
|
95
|
+
return;
|
|
96
|
+
entry.timer = setTimeout(() => {
|
|
97
|
+
entry.timer = null;
|
|
98
|
+
const live = this.entries.get(key);
|
|
99
|
+
if (live)
|
|
100
|
+
this.flush(live);
|
|
101
|
+
}, FLUSH_MS);
|
|
102
|
+
// Don't let a pending flush keep a shutting-down process alive.
|
|
103
|
+
entry.timer.unref?.();
|
|
104
|
+
}
|
|
105
|
+
flush(entry) {
|
|
106
|
+
if (entry.listeners.size === 0)
|
|
107
|
+
return;
|
|
108
|
+
let snapshot;
|
|
109
|
+
let data;
|
|
110
|
+
try {
|
|
111
|
+
snapshot = entry.agent.liveSnapshot(entry.key);
|
|
112
|
+
data = JSON.stringify(snapshot);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (data === entry.lastSent)
|
|
118
|
+
return;
|
|
119
|
+
entry.lastSent = data;
|
|
120
|
+
for (const fn of [...entry.listeners]) {
|
|
121
|
+
try {
|
|
122
|
+
fn(snapshot);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Ignore a failing listener; others still get the update.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
pause(key) {
|
|
130
|
+
const entry = this.entries.get(key);
|
|
131
|
+
if (!entry)
|
|
132
|
+
return false;
|
|
133
|
+
entry.agent.livePause();
|
|
134
|
+
this.notify(key);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
resume(key) {
|
|
138
|
+
const entry = this.entries.get(key);
|
|
139
|
+
if (!entry)
|
|
140
|
+
return false;
|
|
141
|
+
entry.agent.liveResume();
|
|
142
|
+
this.notify(key);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
send(key, text) {
|
|
146
|
+
const entry = this.entries.get(key);
|
|
147
|
+
if (!entry)
|
|
148
|
+
return false;
|
|
149
|
+
entry.agent.liveSend(text);
|
|
150
|
+
this.notify(key);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
approve(key, approved) {
|
|
154
|
+
const entry = this.entries.get(key);
|
|
155
|
+
if (!entry || !entry.agent.liveApprove)
|
|
156
|
+
return false;
|
|
157
|
+
const handled = entry.agent.liveApprove(approved);
|
|
158
|
+
this.notify(key);
|
|
159
|
+
return handled;
|
|
160
|
+
}
|
|
161
|
+
configure(key, selection) {
|
|
162
|
+
const entry = this.entries.get(key);
|
|
163
|
+
if (!entry?.agent.liveConfigure)
|
|
164
|
+
return { ok: false, error: "this session cannot change models" };
|
|
165
|
+
const result = entry.agent.liveConfigure(selection);
|
|
166
|
+
if (result.ok)
|
|
167
|
+
this.notify(key);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
setApprovalMode(key, mode) {
|
|
171
|
+
const entry = this.entries.get(key);
|
|
172
|
+
if (!entry?.agent.liveSetApprovalMode)
|
|
173
|
+
return false;
|
|
174
|
+
entry.agent.liveSetApprovalMode(mode);
|
|
175
|
+
this.notify(key);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Process-wide singleton shared by the TUI and the web server. */
|
|
180
|
+
export const hub = new LiveHub();
|