@theprototype/agent 1.48.0 → 1.50.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/src/mcpServer.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // is up (never a hardcoded catalog: it is the live app's set, physics and mesh gen included
5
5
  // when the host offers them) and re-read on a poll and after every call, so a host that turns
6
6
  // a provider on, or a role change, reaches the client as `notifications/tools/list_changed`.
7
- // Beside them sit the bridge's own four: connect, status, screenshot, wait_for_edit.
7
+ // Beside them sit the bridge's own four: connect, status, screenshot, wait_for_edit — and (roadmap 56,
8
+ // browser engine) the chat: wait_for_message, reply, ask, get_thread (./chat.js), so people can type to the
9
+ // agent in the app's AI assistant window while the terminal stays a second window onto the same thread.
8
10
  //
9
11
  // The server answers IMMEDIATELY and connects in the background: approval is a human's, so no
10
12
  // tool registration ever waits on it — scene tools refuse with "not in the session yet" until
@@ -14,6 +16,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
17
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
16
18
  import { bridgeTools, mapHostTools, toolSignature, textResult, resultIsError } from './mcpTools.js';
19
+ import { chatTools, waitMs, ImageSpool, messageContent, withImportPath, execBridged } from './chat.js';
17
20
  import { parseLink } from './link.js';
18
21
 
19
22
  export const PKG_VERSION = (() => {
@@ -27,7 +30,10 @@ export const PKG_VERSION = (() => {
27
30
  const INSTRUCTIONS = [
28
31
  'theprototype.app session bridge: you are a peer in a live, shared 3D scene. Everything you change replicates to everyone in the session, with undo.',
29
32
  'Start with status (or connect with the session link the host gave you). The scene tools are the host app\'s own and appear once you are in;',
30
- 'call list_scene first so you use real uuids, batch many objects into one call, and use screenshot to look at the result. wait_for_edit waits for the humans.'
33
+ 'call list_scene first so you use real uuids, batch many objects into one call, and use screenshot to look at the result. wait_for_edit waits for the humans.',
34
+ 'People can also TYPE TO YOU in the app\'s AI assistant window: when the user asks you to listen (e.g. "listen to my theprototype session"), loop',
35
+ 'wait_for_message → act → reply({text, final: true}) → wait_for_message (null = nobody wrote yet: wait again). Respect each message\'s role (viewers may not edit).',
36
+ 'When the user talks to you in this terminal while you are in a session, echo it: reply({prompt: <their words>, text: <your answer>, final: true}), so the app\'s thread is complete.'
31
37
  ].join(' ');
32
38
 
33
39
  const CONNECT_WAIT_MS = 45000;
@@ -44,11 +50,13 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
50
  * @param {any[]} [opts.staticTools] host tools known before connecting (the wire engine's catalog)
45
51
  * @param {number} [opts.pollMs] how often to re-read the host's tool list (default 5000)
46
52
  * @param {number} [opts.connectWaitMs]
53
+ * @param {ImageSpool | null} [opts.spool] where chat images are written (null = no temp files)
47
54
  * @param {(m: string) => void} [opts.log]
48
55
  */
49
56
  export function createBridgeServer(opts) {
50
57
  const log = opts.log || (() => {});
51
- const builtins = bridgeTools(opts.kind);
58
+ const builtins = [...bridgeTools(opts.kind), ...(opts.kind === 'browser' ? chatTools() : [])];
59
+ const spool = opts.spool === undefined ? new ImageSpool() : opts.spool;
52
60
  const reserved = builtins.map((t) => t.name);
53
61
  /** @type {any} */
54
62
  let engine = null;
@@ -66,7 +74,7 @@ export function createBridgeServer(opts) {
66
74
 
67
75
  if (opts.staticTools) {
68
76
  const m = mapHostTools(opts.staticTools, reserved);
69
- hostTools = m.tools;
77
+ hostTools = m.tools.map(withImportPath);
70
78
  dropped = m.dropped;
71
79
  signature = toolSignature(hostTools);
72
80
  }
@@ -94,10 +102,11 @@ export function createBridgeServer(opts) {
94
102
  return;
95
103
  }
96
104
  const m = mapHostTools(raw, reserved);
97
- const sig = toolSignature(m.tools);
105
+ const mapped = m.tools.map(withImportPath);
106
+ const sig = toolSignature(mapped);
98
107
  if (sig !== signature) {
99
108
  signature = sig;
100
- hostTools = m.tools;
109
+ hostTools = mapped;
101
110
  dropped = m.dropped;
102
111
  log('host tool list: ' + hostTools.length + ' tools');
103
112
  notifyChanged();
@@ -131,7 +140,11 @@ export function createBridgeServer(opts) {
131
140
  const mine = engine;
132
141
  starting = engine
133
142
  .start()
134
- .then(() => refreshTools())
143
+ .then(() => {
144
+ // 56: the window labels the agent "via MCP" (an older page has no chat: fine)
145
+ if (engine === mine && mine.kind === 'browser') mine.chat?.('chatStatus', null, { mode: 'mcp' }).catch(() => {});
146
+ return refreshTools();
147
+ })
135
148
  .catch((/** @type {any} */ e) => log('connect failed: ' + e.message));
136
149
  if (!poll) {
137
150
  poll = setInterval(() => refreshTools().catch(() => {}), opts.pollMs ?? 5000);
@@ -178,7 +191,7 @@ export function createBridgeServer(opts) {
178
191
 
179
192
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...builtins, ...hostTools] }));
180
193
 
181
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
194
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
182
195
  const name = req.params.name;
183
196
  const a = /** @type {any} */ (req.params.arguments || {});
184
197
  try {
@@ -210,6 +223,10 @@ export function createBridgeServer(opts) {
210
223
  const r = await engine.waitForEdit(ms);
211
224
  return textResult(r, resultIsError(r));
212
225
  }
226
+ if (name === 'wait_for_message' || name === 'reply' || name === 'ask' || name === 'get_thread') {
227
+ if (!engineReady()) return textResult({ error: 'the agent page is not up yet (' + engine.state + ')' }, true);
228
+ return await chatCall(name, a, extra?.signal);
229
+ }
213
230
  if (!hostTools.some((t) => t.name === name)) {
214
231
  return textResult({ error: 'unknown tool: ' + name, note: engineReady() ? 'call status for the tools this session offers' : 'the scene tools appear once connected' }, true);
215
232
  }
@@ -223,7 +240,7 @@ export function createBridgeServer(opts) {
223
240
  true
224
241
  );
225
242
  }
226
- const r = await engine.exec(name, a);
243
+ const r = await execBridged(engine, name, a);
227
244
  refreshTools().catch(() => {});
228
245
  if (r && r.__missing) return textResult({ error: 'this app build has no exec()' }, true);
229
246
  return textResult(r, resultIsError(r));
@@ -232,9 +249,45 @@ export function createBridgeServer(opts) {
232
249
  }
233
250
  });
234
251
 
252
+ /**
253
+ * 56: the chat tools. wait_for_message long-polls the page's inbox; if the client gives up on the call
254
+ * (cancel / timeout) the page's wait is released, and a message that arrived in that instant goes back.
255
+ * @param {string} name @param {any} a @param {AbortSignal | undefined} signal
256
+ */
257
+ async function chatCall(name, a, signal) {
258
+ const e = engine;
259
+ if (name === 'wait_for_message') {
260
+ const token = 'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
261
+ const release = () => e.chat('abortWait', token).catch(() => {});
262
+ signal?.addEventListener('abort', release, { once: true });
263
+ try {
264
+ const msg = await e.chat('nextMessage', waitMs(a.timeoutS ?? a.timeout_s), token);
265
+ if (!msg) return textResult(null);
266
+ if (signal?.aborted) {
267
+ await e.chat('requeue', msg).catch(() => {});
268
+ return textResult({ error: 'cancelled' }, true);
269
+ }
270
+ return { content: messageContent(msg, spool) };
271
+ } finally {
272
+ signal?.removeEventListener('abort', release);
273
+ }
274
+ }
275
+ if (name === 'reply') {
276
+ const r = await e.chat('reply', { text: a.text, final: a.final, to: a.to, chatId: a.chatId ?? a.chat_id, prompt: a.prompt, replyTo: a.replyTo });
277
+ return textResult(r, !r?.ok);
278
+ }
279
+ if (name === 'ask') {
280
+ const r = await e.chat('ask', { question: a.question, options: a.options, to: a.to, chatId: a.chatId ?? a.chat_id });
281
+ return textResult(r, !r?.ok);
282
+ }
283
+ const r = await e.chat('thread', { chatId: a.chatId ?? a.chat_id, before: a.before, limit: a.limit });
284
+ return textResult(r);
285
+ }
286
+
235
287
  async function shutdown() {
236
288
  if (closed) return;
237
289
  closed = true;
290
+ spool?.cleanup();
238
291
  if (poll) clearInterval(poll);
239
292
  const e = engine;
240
293
  engine = null;
package/src/repl.js CHANGED
@@ -1,11 +1,226 @@
1
- // REPL mode (roadmap 10 B4 → 55 A3): a readline loop that drives the scene through an
2
- // OpenAI-compatible endpoint's tool calls. Either engine: the browser engine hands the model
3
- // the host app's own tools and system prompt; the wire engine its fixed object catalog.
4
- // Slash commands: /status /tools /raw <tool> <json> /quit.
1
+ // REPL mode (roadmap 10 B4 → 55 A3 → 56 C3/C4): your own OpenAI-compatible model drives the scene. Either
2
+ // engine: the browser engine hands the model the host app's own tools and system prompt; the wire engine its
3
+ // fixed object catalog. Slash commands: /status /tools /raw <tool> <json> /quit.
4
+ //
5
+ // 56: the bridge owns the model loop here, so a message typed in the app's AI assistant window goes STRAIGHT
6
+ // IN as a user turn — no terminal needed after launch. Terminal lines and app messages share ONE transcript,
7
+ // run one at a time; the reply streams into the window (and the terminal), terminal turns show "via
8
+ // terminal", and Stop in the window ends the turn (the page answers `cancelled` to the next tool call).
5
9
  import readline from 'node:readline';
6
10
  import { runChat } from './llmClient.js';
7
11
  import { mapHostTools } from './mcpTools.js';
8
12
  import { redactLink } from './link.js';
13
+ import { execBridged } from './chat.js';
14
+
15
+ /** how often a streamed reply is pushed to the window (each push carries the full text so far) */
16
+ const FLUSH_MS = 250;
17
+ /** how long one app wait lasts before it re-arms */
18
+ const APP_WAIT_MS = 25000;
19
+
20
+ /**
21
+ * One REPL conversation over an engine. Exported for the tests (a fake engine + a stub endpoint).
22
+ * @param {Object} o
23
+ * @param {any} o.engine
24
+ * @param {{apiUrl: string, apiKey: string, model: string}} o.cfg
25
+ * @param {(s: string) => void} [o.out] terminal output
26
+ * @param {(s: string) => void} [o.log] stderr notes
27
+ * @param {number} [o.flushMs]
28
+ * @param {number} [o.appWaitMs]
29
+ */
30
+ export function createReplSession(o) {
31
+ const { engine, cfg } = o;
32
+ const out = o.out ?? ((s) => process.stdout.write(s));
33
+ const log = o.log ?? ((s) => console.error('[agent] ' + s));
34
+ const flushMs = o.flushMs ?? FLUSH_MS;
35
+ /** does this page take app messages? (browser engine on tpAgent 1.1.0) */
36
+ let chatOn = engine.kind === 'browser' && typeof engine.chat === 'function';
37
+ /** @type {any[]} */
38
+ const history = [];
39
+ /** @type {Promise<any>} */
40
+ let busy = Promise.resolve();
41
+ let pendingTerminal = 0;
42
+ let closed = false;
43
+ /** @type {string | null} */
44
+ let waitToken = null;
45
+
46
+ const toolsFor = async () => mapHostTools(await engine.tools(), []).tools.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }));
47
+
48
+ /** @param {string} method @param {...any} args */
49
+ const chat = (method, ...args) => (chatOn ? engine.chat(method, ...args) : Promise.resolve(null));
50
+
51
+ /** @param {() => Promise<any>} fn */
52
+ function enqueue(fn) {
53
+ const job = busy.then(fn);
54
+ busy = job.catch(() => {});
55
+ return job;
56
+ }
57
+
58
+ /**
59
+ * A streamed reply into the window: deltas batched every flushMs, the last push final.
60
+ * @param {any} ids replyTo/to/chatId for an app request, or {via:'cli'} for a terminal turn
61
+ */
62
+ function streamer(ids) {
63
+ let pending = '';
64
+ let any = false;
65
+ let lastRound = 0;
66
+ /** @type {ReturnType<typeof setTimeout> | null} */
67
+ let timer = null;
68
+ let sending = Promise.resolve();
69
+ const push = (/** @type {boolean} */ final) => {
70
+ const delta = pending;
71
+ pending = '';
72
+ sending = sending.then(() => chat('reply', { ...ids, delta, final }).catch(() => null));
73
+ return sending;
74
+ };
75
+ return {
76
+ /** @param {string} t @param {number} round */
77
+ delta(t, round) {
78
+ if (any && round !== lastRound) {
79
+ pending += '\n\n';
80
+ out('\n\n');
81
+ }
82
+ lastRound = round;
83
+ any = true;
84
+ pending += t;
85
+ out(t);
86
+ if (!timer) timer = setTimeout(() => ((timer = null), push(false)), flushMs);
87
+ },
88
+ /** @param {string} [tail] */
89
+ async end(tail) {
90
+ if (timer) clearTimeout(timer);
91
+ timer = null;
92
+ if (tail) {
93
+ pending += (any ? '\n\n' : '') + tail;
94
+ out((any ? '\n\n' : '') + tail);
95
+ }
96
+ out('\n');
97
+ return push(true);
98
+ }
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Run one turn of the model with streaming. Returns 'done' | 'stopped'.
104
+ * @param {any} user the user message (text or content parts) @param {any} ids
105
+ */
106
+ async function turn(user, ids) {
107
+ if (!history.length) history.push({ role: 'system', content: await engine.systemPrompt() });
108
+ history.push({ role: 'user', content: user });
109
+ const s = streamer(ids);
110
+ let stopped = false;
111
+ try {
112
+ await runChat({
113
+ cfg,
114
+ messages: history,
115
+ tools: await toolsFor(),
116
+ executeTool: (name, a) => execBridged(engine, name, a), // local `path`s for import_model / import_file
117
+ onTool: (name, a) => out('\n · ' + name + ' ' + summarizeArgs(a) + '\n'),
118
+ onDelta: (t, round) => s.delta(t, round),
119
+ stopOn: (r) => {
120
+ if (r && r.cancelled) stopped = true;
121
+ return stopped;
122
+ }
123
+ });
124
+ } catch (e) {
125
+ const msg = String(/** @type {any} */ (e).message || e).split('\n')[0];
126
+ // an endpoint without vision: say so once and go on in text
127
+ if (Array.isArray(user) && /image|vision|content/i.test(msg) && !/^API 401/.test(msg)) {
128
+ history.pop();
129
+ const text = user.filter((p) => p.type === 'text').map((p) => p.text).join('\n');
130
+ log('this model refused the images (' + msg.slice(0, 80) + '); retrying without them');
131
+ return turn(text + '\n(The person attached images, but this model cannot see images.)', ids);
132
+ }
133
+ await s.end('Error: ' + msg);
134
+ throw e;
135
+ }
136
+ await s.end(stopped ? '(stopped)' : undefined);
137
+ if (stopped) history.push({ role: 'system', content: 'The person pressed Stop: that request was cancelled. Do not continue it.' });
138
+ return stopped ? 'stopped' : 'done';
139
+ }
140
+
141
+ /** A line typed in the terminal: echoed into the app's thread as "via terminal", then answered. @param {string} text */
142
+ function terminal(text) {
143
+ pendingTerminal++;
144
+ if (waitToken) chat('abortWait', waitToken).catch(() => {});
145
+ return enqueue(async () => {
146
+ try {
147
+ await chat('reply', { prompt: text, via: 'cli' }).catch(() => null);
148
+ return await turn(text, { via: 'cli' });
149
+ } finally {
150
+ pendingTerminal--;
151
+ }
152
+ });
153
+ }
154
+
155
+ /** A message typed in the app's window, as a user turn. @param {any} msg */
156
+ function appTurn(msg) {
157
+ return enqueue(async () => {
158
+ const who = (msg.name || String(msg.from || '').toUpperCase() || 'someone') + ' (' + (msg.role || 'editor') + ')';
159
+ out('\n[app] ' + who + ': ' + (msg.text || '') + (msg.images?.length ? ' [' + msg.images.length + ' image' + (msg.images.length > 1 ? 's' : '') + ']' : '') + '\n');
160
+ if (msg.history?.length) {
161
+ const lines = msg.history.map((/** @type {any} */ h) => (h.name || h.from) + (h.via === 'cli' ? ' (terminal)' : '') + ': ' + String(h.text || '').slice(0, 400));
162
+ history.push({ role: 'system', content: 'Earlier in this chat (' + msg.chatId + '). ' + (msg.summary || '') + '\n' + lines.join('\n') });
163
+ }
164
+ let text = '[' + who + ' in the app' + (msg.answers ? ', answering your question' : '') + ']: ' + (msg.text || '');
165
+ if (msg.role === 'viewer') text += '\n(This person is a VIEWER: answer, but do not change the scene for them — edits are refused.)';
166
+ const user = msg.images?.length
167
+ ? [{ type: 'text', text }, ...msg.images.map((/** @type {any} */ im) => ({ type: 'image_url', image_url: { url: 'data:' + im.mimeType + ';base64,' + im.data } }))]
168
+ : text;
169
+ return turn(user, { replyTo: msg.id, to: msg.from, chatId: msg.chatId }).catch((e) => log('turn failed: ' + e.message));
170
+ });
171
+ }
172
+
173
+ /** Pick up app messages until stopped (each one waits for the turn before it). */
174
+ async function appLoop() {
175
+ if (!chatOn) return;
176
+ await chat('chatStatus', null, { mode: 'repl', model: cfg.model }).catch(() => {});
177
+ while (!closed) {
178
+ await busy;
179
+ if (closed) break;
180
+ const token = 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
181
+ waitToken = token;
182
+ let msg;
183
+ try {
184
+ msg = await engine.chat('nextMessage', o.appWaitMs ?? APP_WAIT_MS, token);
185
+ } catch (e) {
186
+ waitToken = null;
187
+ if (/no agent chat/.test(String(/** @type {any} */ (e).message))) {
188
+ log('this app build has no agent chat: only the terminal talks to the model');
189
+ chatOn = false;
190
+ return;
191
+ }
192
+ await new Promise((r) => setTimeout(r, 1000));
193
+ continue;
194
+ }
195
+ waitToken = null;
196
+ if (!msg) continue;
197
+ if (pendingTerminal > 0) {
198
+ // a terminal line got in first: this one goes back and waits its turn
199
+ await chat('requeue', msg).catch(() => {});
200
+ continue;
201
+ }
202
+ appTurn(msg);
203
+ }
204
+ }
205
+
206
+ return {
207
+ history,
208
+ terminal,
209
+ appTurn,
210
+ appLoop,
211
+ toolsFor,
212
+ get busy() {
213
+ return busy;
214
+ },
215
+ get chatOn() {
216
+ return chatOn;
217
+ },
218
+ stop() {
219
+ closed = true;
220
+ if (waitToken) chat('abortWait', waitToken).catch(() => {});
221
+ }
222
+ };
223
+ }
9
224
 
10
225
  /**
11
226
  * @param {Record<string, any>} args
@@ -21,11 +236,15 @@ export async function runRepl(args, link, makeEngine) {
21
236
  if (!cfg.apiUrl || !cfg.model) throw new Error('repl needs --api-url and --model (or env AGENT_API_URL / AGENT_MODEL)');
22
237
 
23
238
  const engine = makeEngine(link);
239
+ /** @type {ReturnType<typeof createReplSession> | null} */
240
+ let session = null;
24
241
  const quit = async (/** @type {number} */ code) => {
242
+ session?.stop();
25
243
  await engine.close().catch(() => {});
26
244
  process.exit(code);
27
245
  };
28
246
  process.on('SIGINT', () => quit(0));
247
+ process.on('SIGTERM', () => quit(0));
29
248
  console.error('[agent] joining ' + redactLink(link) + ' (' + engine.kind + ' engine)…');
30
249
  await engine.start();
31
250
  console.error('[agent] waiting for the host to let us in (Approve, or an invite link)…');
@@ -37,39 +256,36 @@ export async function runRepl(args, link, makeEngine) {
37
256
  }
38
257
  await new Promise((r) => setTimeout(r, 500));
39
258
  }
40
- console.error('[agent] in the session. Type a request, or /help. Ctrl+C to quit.\n');
259
+ session = createReplSession({ engine, cfg });
260
+ const s = session;
261
+ console.error(
262
+ '[agent] in the session. Type a request, or /help. Ctrl+C to quit.' +
263
+ (s.chatOn ? ' People can also type to ' + cfg.model + ' in the app\'s AI assistant window.' : '') +
264
+ '\n'
265
+ );
41
266
 
42
- const toolsFor = async () => mapHostTools(await engine.tools(), []).tools.map((t) => ({ name: t.name, description: t.description, parameters: t.inputSchema }));
43
- /** @type {any[]} */
44
- const history = [{ role: 'system', content: await engine.systemPrompt() }];
45
-
46
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'scene> ' });
267
+ const interactive = !!process.stdin.isTTY;
268
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: 'scene> ', terminal: interactive });
269
+ s.appLoop().catch((e) => console.error('[agent] app messages stopped: ' + e.message));
47
270
  rl.prompt();
48
271
 
49
272
  rl.on('line', async (line) => {
50
273
  const text = line.trim();
51
274
  if (!text) return rl.prompt();
52
- rl.pause();
53
275
  try {
54
- if (text.startsWith('/')) await handleSlash(text, engine, rl, toolsFor);
55
- else {
56
- history.push({ role: 'user', content: text });
57
- const reply = await runChat({
58
- cfg,
59
- messages: history,
60
- tools: await toolsFor(),
61
- executeTool: (name, a) => engine.exec(name, a),
62
- onTool: (name, a) => console.log(' · ' + name + ' ' + summarizeArgs(a))
63
- });
64
- if (reply) console.log('\n' + reply + '\n');
276
+ if (text.startsWith('/')) {
277
+ if ((await handleSlash(text, engine, s.toolsFor)) === 'quit') return quit(0);
65
278
  }
279
+ else await s.terminal(text);
66
280
  } catch (e) {
67
281
  console.error('[agent] error: ' + /** @type {any} */ (e).message + '\n');
68
282
  }
69
- rl.resume();
70
283
  rl.prompt();
71
284
  });
72
- rl.on('close', () => quit(0));
285
+ // a closed stdin (a script, `--repl < /dev/null`) keeps the agent answering the app; Ctrl+C quits
286
+ rl.on('close', () => {
287
+ if (interactive || !s.chatOn) quit(0);
288
+ });
73
289
  }
74
290
 
75
291
  /** @param {any} a */
@@ -78,10 +294,10 @@ function summarizeArgs(a) {
78
294
  return '';
79
295
  }
80
296
 
81
- /** @param {string} text @param {any} engine @param {any} rl @param {() => Promise<any[]>} toolsFor */
82
- async function handleSlash(text, engine, rl, toolsFor) {
297
+ /** @param {string} text @param {any} engine @param {() => Promise<any[]>} toolsFor @returns {Promise<'quit' | void>} */
298
+ async function handleSlash(text, engine, toolsFor) {
83
299
  const [cmd, ...rest] = text.slice(1).split(/\s+/);
84
- if (cmd === 'quit' || cmd === 'q' || cmd === 'exit') return rl.close();
300
+ if (cmd === 'quit' || cmd === 'q' || cmd === 'exit') return 'quit';
85
301
  if (cmd === 'status') console.log(JSON.stringify({ ...engine.describe(), session: await engine.status() }, null, 2));
86
302
  else if (cmd === 'tools') for (const t of await toolsFor()) console.log(' ' + t.name);
87
303
  else if (cmd === 'raw') {