aegiscode 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js CHANGED
@@ -9,15 +9,29 @@
9
9
  * real streaming and real command dispatch without a TTY and without a child
10
10
  * process. Bin entry = argument parsing and process lifecycle; everything else
11
11
  * lives here.
12
+ *
13
+ * Commands come from `./commands.js`. Each entry is either handler-backed
14
+ * (`cmd.handler(c, args)`, the reference aegiscodex-dev contract), tool-backed
15
+ * (`cmd.tool` + `cmd.build`), a generic escape hatch (`cmd.generic`, `/tool`),
16
+ * or `unavailable` (a Claude Code auth-loop command this client cannot honour).
17
+ * Handlers run against the FROZEN command context built below; the tool path
18
+ * and the `/tool` escape hatch are unchanged from the previous revision.
12
19
  */
13
20
 
14
21
  const readline = require('node:readline');
22
+ const os = require('node:os');
23
+ const { randomUUID } = require('node:crypto');
15
24
  const { createTools, createClient, usageTokens } = require('./deps.js');
16
- const { GLYPH, VERBS, themeOf, RESET, BOLD } = require('./theme.js');
17
- const { LiveRegion, termWidth, EC, w } = require('./screen.js');
18
- const { parseLine, findCommand, COMMANDS } = require('./commands.js');
25
+ const { createEngine } = require('./engine.js');
26
+ const { GLYPH, VERBS, themeOf, RESET } = require('./theme.js');
27
+ const { LiveRegion, termWidth, w } = require('./screen.js');
28
+ const { parseLine, COMMANDS, visibleCommands } = require('./commands.js');
29
+ const { updateConfig, loadPermissions } = require('./config.js');
30
+ const { readSessionTranscript } = require('./history.js');
31
+ const chatflow = require('./chatflow.js');
32
+ const overlays = require('./overlays.js');
19
33
  const render = require('./render.js');
20
- const { fmtTokens, fmtEur, maskKey, fmtElapsed } = require('./format.js');
34
+ const { fmtTokens, fmtEur, maskKey } = require('./format.js');
21
35
 
22
36
  const VERSION = require('../package.json').version;
23
37
 
@@ -36,9 +50,57 @@ function createApp(options = {}) {
36
50
  const client = options.client || createClient();
37
51
  const { TOOLS, toolList } = options.tools || createTools(client);
38
52
 
39
- const ctx = () => ({ light: opts.light });
53
+ // Tool-approval gate (exec/writeFile/editFile confirm before running).
54
+ // Read per call, so /permissions and /yolo take effect on the very next tool
55
+ // round rather than needing a restart. The mode is read LIVE from the
56
+ // permission store rather than from a boolean captured at construction:
57
+ // `/yolo` and `/confirm` write the store, so a captured flag made both
58
+ // commands cosmetic — the panel changed while the engine kept asking.
59
+ let confirmMode = options.confirmMode !== false;
60
+ const engine =
61
+ options.engine ||
62
+ createEngine({
63
+ client,
64
+ getConfirmMode: () => {
65
+ if (options.confirmMode !== undefined) return confirmMode;
66
+ try {
67
+ return loadPermissions().defaultMode !== 'allow';
68
+ } catch {
69
+ return confirmMode;
70
+ }
71
+ },
72
+ });
73
+ // Set by runInteractive: a question/answer channel for the engine's
74
+ // tool-approval requests. Left null in one-shot (-p) runs, where there is
75
+ // no one to ask — see the approval branch in ask() below.
76
+ let approvalPrompter = null;
77
+
40
78
  const width = () => (options.width ? options.width() : termWidth());
41
79
 
80
+ // ── live session/command state ─────────────────────────────────────────────
81
+ // `commandCtx` is the FROZEN `c.ctx` a handler receives: the mutable slice of
82
+ // session state a command may set (model, effort, thinking, theme, vim, stream,
83
+ // cwd, lastRecap). The renderers read their colours from it via ctx().
84
+ const commandCtx = {
85
+ light: opts.light,
86
+ model: opts.model,
87
+ effort: 'high',
88
+ thinking: false,
89
+ themeIndex: opts.light ? 0 : 1,
90
+ vim: false,
91
+ stream: opts.stream,
92
+ cwd: process.cwd(),
93
+ lastRecap: null,
94
+ sessionId: randomUUID(),
95
+ };
96
+ // The transcript rows a handler reads/pushes (user/assistant/note/panel/…).
97
+ // It is also the source of the engine's conversation history, so /clear and
98
+ // /new genuinely reset context.
99
+ const transcript = [];
100
+ let wantExit = false;
101
+
102
+ const ctx = () => ({ light: commandCtx.light });
103
+
42
104
  const session = {
43
105
  turns: 0,
44
106
  calls: 0,
@@ -51,7 +113,7 @@ function createApp(options = {}) {
51
113
  startedAt: Date.now(),
52
114
  };
53
115
 
54
- let abortController = null;
116
+ let activeSessionId = null;
55
117
  let closed = false;
56
118
 
57
119
  // --- helpers --------------------------------------------------------------
@@ -86,10 +148,10 @@ function createApp(options = {}) {
86
148
  return render.renderBanner(ctx(), {
87
149
  width: width(),
88
150
  version: VERSION,
89
- model: opts.model || 'server default',
151
+ model: commandCtx.model || 'server default',
90
152
  base: client.apiBase,
91
153
  key: maskKey(client.apiKey),
92
- stream: opts.stream,
154
+ stream: commandCtx.stream,
93
155
  });
94
156
  }
95
157
 
@@ -106,18 +168,27 @@ function createApp(options = {}) {
106
168
  // --- the ask path ---------------------------------------------------------
107
169
 
108
170
  /**
109
- * One pooled call, streamed into the live region.
171
+ * One turn through the agent-loop engine (persistent-shell exec,
172
+ * readFile/writeFile/editFile/listDir/glob/grep, Task subagents), streamed
173
+ * into the live region. `history` is every prior turn's user/assistant pair
174
+ * — never that turn's own tool-call scratchpad, which the engine keeps
175
+ * internally and never returns (see engine.js's `chat()`).
110
176
  * @returns {Promise<{text:string, usage:object|null, model:string|null, ms:number, interrupted:boolean}>}
111
177
  */
112
- async function ask(prompt) {
178
+ async function ask(prompt, { history = [], presenter = null, signal = null } = {}) {
113
179
  const started = Date.now();
114
- const live = opts.interactive && opts.stream && out.isTTY ? new LiveRegion(out) : null;
180
+ // A presenter takes over ALL presentation (the chatflow's frame paints the
181
+ // streaming answer into a transcript row), so the linear/inline live region
182
+ // is only built when there is none.
183
+ const live =
184
+ !presenter && opts.interactive && opts.stream && out.isTTY ? new LiveRegion(out) : null;
115
185
  let tick = 0;
116
186
  let chars = 0;
117
187
  let partial = '';
118
188
  let reasoning = 0;
119
189
  let verb = VERBS[Math.floor(Math.random() * VERBS.length)];
120
190
  let sawReasoning = false;
191
+ let usedTools = false;
121
192
 
122
193
  const paint = () => {
123
194
  if (!live) return;
@@ -135,56 +206,121 @@ function createApp(options = {}) {
135
206
  if (timer && timer.unref) timer.unref();
136
207
  paint();
137
208
 
138
- abortController = new AbortController();
209
+ // Presentation hooks. With no presenter these reproduce the linear
210
+ // behaviour exactly (live-region spinner, tool rows written to scrollback).
211
+ const p = presenter || {};
212
+ const present = {
213
+ text: (d) => {
214
+ if (p.text) return p.text(d);
215
+ if (live && tick % 2 === 0) paint();
216
+ return undefined;
217
+ },
218
+ reasoning: (r) => {
219
+ if (p.reasoning) return p.reasoning(r);
220
+ return undefined;
221
+ },
222
+ tool: (tool) => {
223
+ if (p.tool) return p.tool(tool);
224
+ if (live) live.clear();
225
+ emit(
226
+ render.renderTurn(
227
+ ctx(),
228
+ { role: 'tool', label: tool.name, args: tool.args, ok: tool.ok },
229
+ width()
230
+ )
231
+ );
232
+ paint();
233
+ return undefined;
234
+ },
235
+ };
236
+
237
+ const sessionId = randomUUID();
238
+ activeSessionId = sessionId;
139
239
  let interrupted = false;
140
- const onSigint = () => {
240
+ // One cancel path for both callers: a SIGINT from the process, and the
241
+ // chatflow's Esc, which aborts the controller it handed in. Without this
242
+ // second source the full-screen loop's Esc had nothing listening to it —
243
+ // the turn kept running and billing while the UI said "stopped".
244
+ const cancel = () => {
141
245
  interrupted = true;
142
- if (abortController) abortController.abort();
246
+ engine.cancel(sessionId);
143
247
  };
248
+ const onSigint = () => cancel();
144
249
  process.once('SIGINT', onSigint);
250
+ if (signal) {
251
+ if (signal.aborted) cancel();
252
+ else signal.addEventListener('abort', cancel, { once: true });
253
+ }
254
+
255
+ // A denial with no one to ask it of: -p and any other run with no
256
+ // approvalPrompter set. Fails safe (deny) rather than hanging the tool
257
+ // round forever waiting for an answer nobody can give.
258
+ const denyNoPrompter = (info) => {
259
+ err.write(
260
+ `aegiscode: ${info.tool} needs confirmation but this run has no prompt for it — denied ` +
261
+ '(pass --yolo to auto-approve mutating tools).\n'
262
+ );
263
+ return Promise.resolve('deny');
264
+ };
265
+
266
+ const onDelta = (chunk) => {
267
+ if (!chunk) return;
268
+ if (chunk.reasoning) {
269
+ reasoning += w(chunk.reasoning);
270
+ // The pool's worker findings arrive on the reasoning channel before
271
+ // the answer; say so rather than looking stalled.
272
+ if (!sawReasoning) {
273
+ sawReasoning = true;
274
+ verb = 'Reasoning';
275
+ paint();
276
+ }
277
+ present.reasoning(chunk.reasoning);
278
+ }
279
+ if (chunk.delta) {
280
+ chars += w(chunk.delta);
281
+ partial += chunk.delta;
282
+ present.text(chunk.delta);
283
+ }
284
+ if (chunk.tool) {
285
+ usedTools = true;
286
+ present.tool(chunk.tool);
287
+ }
288
+ if (chunk.approval) {
289
+ const info = chunk.approval;
290
+ if (live) live.clear();
291
+ const decide = p.approval || approvalPrompter || denyNoPrompter;
292
+ Promise.resolve(decide(info))
293
+ .catch(() => 'deny')
294
+ .then((decision) => engine.respondApproval(info.id, decision));
295
+ }
296
+ };
145
297
 
146
298
  try {
147
- const res = await client.chatCompletion({
148
- prompt,
149
- model: opts.model || undefined,
150
- system: opts.system,
151
- maxTokens: opts.maxTokens,
152
- stream: Boolean(opts.stream),
153
- // Ask the server for the token count on the streaming path: an
154
- // OpenAI-compatible SSE reply carries no usage unless asked, and this
155
- // is the same wire form the desktop sends.
156
- includeUsage: Boolean(opts.stream),
157
- signal: abortController.signal,
158
- onReasoning: (t) => {
159
- reasoning += w(t);
160
- // The pool's worker findings arrive on the reasoning channel before
161
- // the answer; say so rather than looking stalled.
162
- if (!sawReasoning) {
163
- sawReasoning = true;
164
- verb = 'Reasoning';
165
- }
166
- },
167
- onStream: ({ delta, reasoning: r }) => {
168
- if (r) reasoning += w(r);
169
- if (delta) {
170
- chars += w(delta);
171
- partial += delta;
172
- // Re-paint on every delta but coalesce to ~30fps through the frame
173
- // counter — a fast provider otherwise spends the CPU on escapes.
174
- if (live && tick % 2 === 0) paint();
175
- }
299
+ const res = await engine.chat(
300
+ {
301
+ prompt,
302
+ messages: history,
303
+ model: commandCtx.model || undefined,
304
+ system: opts.system,
305
+ maxTokens: opts.maxTokens,
306
+ // false asks the engine for the buffered (non-stream) wire form, so
307
+ // `--no-stream` and piped runs get a single body rather than SSE.
308
+ stream: commandCtx.stream !== false,
309
+ sessionId,
176
310
  },
177
- });
311
+ onDelta
312
+ );
178
313
 
179
314
  const choice = (res.choices && res.choices[0]) || {};
180
315
  const text = (choice.message && choice.message.content) || partial;
181
316
  return {
182
317
  text,
183
318
  usage: res.usage || null,
184
- model: res.model || opts.model || null,
319
+ model: res.model || commandCtx.model || null,
185
320
  ms: Date.now() - started,
186
321
  interrupted,
187
322
  reasoningChars: reasoning,
323
+ usedTools,
188
324
  };
189
325
  } catch (e) {
190
326
  // A user interrupt is not a failure: keep what already streamed.
@@ -192,18 +328,20 @@ function createApp(options = {}) {
192
328
  return {
193
329
  text: partial,
194
330
  usage: null,
195
- model: opts.model || null,
331
+ model: commandCtx.model || null,
196
332
  ms: Date.now() - started,
197
333
  interrupted: true,
198
334
  reasoningChars: reasoning,
335
+ usedTools,
199
336
  };
200
337
  }
201
338
  throw e;
202
339
  } finally {
203
340
  if (timer) clearInterval(timer);
204
341
  process.removeListener('SIGINT', onSigint);
342
+ if (signal) signal.removeEventListener('abort', cancel);
205
343
  if (live) live.clear();
206
- abortController = null;
344
+ activeSessionId = null;
207
345
  }
208
346
  }
209
347
 
@@ -212,6 +350,13 @@ function createApp(options = {}) {
212
350
  emit(render.renderTurn(ctx(), turn, width()));
213
351
  }
214
352
 
353
+ /** Prior user/assistant pairs from the live transcript, for the engine. */
354
+ function historyPairs() {
355
+ return transcript
356
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
357
+ .map((m) => ({ role: m.role, content: m.text }));
358
+ }
359
+
215
360
  /** Ask, then account for it. Shared by plain prompts and `/ask`. */
216
361
  async function runPrompt(prompt, { label = 'you' } = {}) {
217
362
  if (!prompt) {
@@ -229,25 +374,21 @@ function createApp(options = {}) {
229
374
  return;
230
375
  }
231
376
 
377
+ const history = historyPairs();
232
378
  printTurn({ role: 'user', text: prompt, label });
379
+ transcript.push({ role: 'user', text: prompt });
233
380
 
234
381
  let res;
235
382
  try {
236
- res = await ask(prompt);
383
+ res = await ask(prompt, { history });
237
384
  } catch (e) {
238
385
  emit(render.renderTurn(ctx(), { role: 'error', text: e.message }, width()));
239
386
  return;
240
387
  }
241
388
 
242
- session.turns++;
243
- session.calls++;
389
+ if (res.text) transcript.push({ role: 'assistant', text: res.text });
244
390
 
245
- const tokens = usageTokens(res.usage);
246
- if (tokens != null) {
247
- session.tokens += tokens;
248
- session.inputTokens += Number(res.usage.input_tokens ?? res.usage.prompt_tokens ?? 0) || 0;
249
- session.outputTokens += Number(res.usage.output_tokens ?? res.usage.completion_tokens ?? 0) || 0;
250
- }
391
+ const tokens = recordTurn(res);
251
392
 
252
393
  const spend = await refreshSpend();
253
394
 
@@ -289,18 +430,6 @@ function createApp(options = {}) {
289
430
  emit(render.renderToolResult(ctx(), name, text, width()));
290
431
  }
291
432
 
292
- // Category order and labels, matching aegiscodex-dev's palette.
293
- const CATEGORY_ORDER = ['aegis', 'model', 'session', 'data', 'auth', 'support', 'workspace'];
294
- const CATEGORY_LABEL = {
295
- aegis: 'Aegis plugin',
296
- model: 'Model & behavior',
297
- session: 'Session & context',
298
- data: 'Data',
299
- auth: 'Auth',
300
- support: 'Support',
301
- workspace: 'Workspace',
302
- };
303
-
304
433
  /**
305
434
  * The nearest routable name to a mistyped one: a prefix of it, or a name it
306
435
  * is a prefix of. Deliberately simple — `cli/src/fuzzy.js` is a separate
@@ -323,37 +452,199 @@ function createApp(options = {}) {
323
452
  return best;
324
453
  }
325
454
 
326
- function printHelp() {
327
- const t = themeOf(ctx());
328
- emit([render.renderHeading(ctx(), 'commands', width())]);
329
- const rows = COMMANDS.filter((c) => !c.unavailable).map((c) => ({
330
- cat: c.category || 'other',
331
- usage: `/${c.name}${c.args ? ' ' + c.args : ''}`,
332
- desc: c.desc,
333
- aliases: (c.aliases || []).map((a) => `/${a}`).join(' '),
334
- }));
335
- const widest = rows.reduce((m, r) => Math.max(m, r.usage.length), 0);
336
- for (const cat of CATEGORY_ORDER) {
337
- const group = rows.filter((r) => r.cat === cat);
338
- if (!group.length) continue;
339
- emit(['', `${t.dim}${BOLD}${CATEGORY_LABEL[cat] || cat}${RESET}`]);
340
- for (const r of group) {
341
- const alias = r.aliases ? `${t.dim} (${r.aliases})${RESET}` : '';
342
- emit([
343
- ` ${t.gold}${r.usage}${RESET}${' '.repeat(Math.max(1, widest - r.usage.length + 2))}` +
344
- `${t.white}${r.desc}${RESET}${alias}`,
345
- ]);
455
+ /** Tokenize one argument string, honouring single/double quotes. */
456
+ function tokenize(s) {
457
+ const out = [];
458
+ let cur = '';
459
+ let quote = null;
460
+ let has = false;
461
+ for (const ch of String(s || '')) {
462
+ if (quote) {
463
+ if (ch === quote) quote = null;
464
+ else cur += ch;
465
+ continue;
466
+ }
467
+ if (ch === '"' || ch === "'") { quote = ch; has = true; continue; }
468
+ if (/\s/.test(ch)) {
469
+ if (cur || has) { out.push(cur); cur = ''; has = false; }
470
+ continue;
471
+ }
472
+ cur += ch;
473
+ has = true;
474
+ }
475
+ if (cur || has) out.push(cur);
476
+ return out;
477
+ }
478
+
479
+ /** Positional args keyed by the entry's `args` names, plus `_rest`. */
480
+ function parseArgs(cmd, arg) {
481
+ const raw = String(arg == null ? '' : arg);
482
+ const names = Array.isArray(cmd.args) ? cmd.args : [];
483
+ const tokens = tokenize(raw);
484
+ const out = { _rest: raw.trim() };
485
+ names.forEach((n, i) => { if (tokens[i] !== undefined) out[n] = tokens[i]; });
486
+ return out;
487
+ }
488
+
489
+ /** A fresh snapshot for panels.js (the frozen `c.state()` shape). */
490
+ function buildState() {
491
+ const rules = loadPermissions();
492
+ return {
493
+ version: VERSION,
494
+ model: commandCtx.model || 'server default',
495
+ effort: commandCtx.effort,
496
+ thinking: commandCtx.thinking,
497
+ theme: commandCtx.light ? 'light' : 'dark',
498
+ themeIndex: commandCtx.themeIndex,
499
+ vim: commandCtx.vim,
500
+ stream: commandCtx.stream,
501
+ cwd: commandCtx.cwd,
502
+ home: os.homedir(),
503
+ sessionId: commandCtx.sessionId,
504
+ base: client.apiBase,
505
+ keyMask: maskKey(client.apiKey),
506
+ online: !!client.apiKey,
507
+ turns: session.turns,
508
+ calls: session.calls,
509
+ startedAt: session.startedAt,
510
+ tokens: { input: session.inputTokens, output: session.outputTokens, total: session.tokens },
511
+ costEur: session.cost,
512
+ balance: session.balance,
513
+ plan: session.plan || null,
514
+ account: session.account || null,
515
+ permissions: { mode: rules.defaultMode, rules },
516
+ models: [],
517
+ commands: visibleCommands(),
518
+ transcript: transcript.slice(),
519
+ sessions: [],
520
+ memory: {},
521
+ lastRecap: commandCtx.lastRecap,
522
+ backend: 'aegis',
523
+ url: client.apiBase,
524
+ };
525
+ }
526
+
527
+ /** Flatten a span line (panels.js/overlays.js output) back to an ANSI row. */
528
+ function flattenSpans(line) {
529
+ if (!Array.isArray(line)) return String(line == null ? '' : line);
530
+ return line.map((sp) => (sp && (sp.s || '') + (sp.t == null ? '' : sp.t)) || '').join('');
531
+ }
532
+
533
+ /**
534
+ * Push one transcript row. `c.push(row)`/`c.note`/`c.panel` land here; the
535
+ * linear CLI prints each row once to scrollback (there is no alt-screen).
536
+ */
537
+ function pushRow(row) {
538
+ if (!row || typeof row !== 'object') return;
539
+ const W = width();
540
+ switch (row.role) {
541
+ case 'panel': {
542
+ const lines = Array.isArray(row.lines) ? row.lines : [];
543
+ for (const line of lines) emit(flattenSpans(line));
544
+ return;
346
545
  }
546
+ case 'note': emit(render.renderNotice(ctx(), 'info', row.text)); return;
547
+ case 'tip': emit(render.renderNotice(ctx(), 'info', row.text)); return;
548
+ case 'done': emit(render.renderNotice(ctx(), 'ok', row.text)); return;
549
+ case 'error': emit(render.renderNotice(ctx(), 'error', row.text)); return;
550
+ case 'user': emit(render.renderTurn(ctx(), { role: 'user', text: row.text, label: row.label }, W)); return;
551
+ case 'assistant': emit(render.renderTurn(ctx(), { role: 'assistant', text: row.text }, W)); return;
552
+ case 'tool':
553
+ emit(render.renderTurn(ctx(), { role: 'tool', label: row.label || row.name, args: row.args, ok: row.ok }, W));
554
+ return;
555
+ default: emit(render.renderNotice(ctx(), 'info', row.text == null ? '' : String(row.text))); return;
556
+ }
557
+ }
558
+
559
+ /** A static (non-interactive) print of an overlay's contents. */
560
+ function openOverlay(o) {
561
+ if (!o || typeof o !== 'object') return;
562
+ const W = width();
563
+ const rows = Math.max(5, (process.stdout && process.stdout.rows) || 24);
564
+ let lines = null;
565
+ if (o.type === 'panel') lines = o.lines;
566
+ else if (o.type === 'palette') lines = overlays.renderPalette(visibleCommands(), { query: o.query || '', sel: o.sel || 0 }, W, rows);
567
+ else if (o.type === 'model') lines = overlays.renderModelPicker(o.items || [], o.sel || 0, W, rows, o.current != null ? o.current : commandCtx.model);
568
+ else if (o.type === 'effort') lines = overlays.renderEffortPicker(o.sel || 0, W, commandCtx.effort);
569
+ else if (o.type === 'resume') lines = overlays.renderResumeList(o.items || [], o.sel || 0, W, rows);
570
+ else if (o.type === 'confirm') lines = render.renderApproval(ctx(), o.info || {}, W).map((s) => [spanRow(s)]);
571
+ if (!lines) return;
572
+ for (const line of lines) emit(flattenSpans(line));
573
+ }
574
+
575
+ /** A single-span row for a pre-styled string (renderApproval output). */
576
+ function spanRow(s) {
577
+ return { t: s, s: '', w: w(s) };
578
+ }
579
+
580
+ /** Run `fn(signal)` with the spinner up (interactive TTY only). */
581
+ async function withWorking(fn) {
582
+ const controller = new AbortController();
583
+ const live = opts.interactive && out.isTTY ? new LiveRegion(out) : null;
584
+ let timer = null;
585
+ if (live) {
586
+ let tick = 0;
587
+ const started = Date.now();
588
+ const paint = () => live.update([render.renderWorking(ctx(), { tick: tick++, verb: VERBS[0], elapsedMs: Date.now() - started })]);
589
+ paint();
590
+ timer = setInterval(paint, 90);
591
+ if (timer.unref) timer.unref();
347
592
  }
348
- const unavail = COMMANDS.filter((c) => c.unavailable).map((c) => `/${c.name}`);
349
- if (unavail.length) {
350
- emit(['', `${t.dim}not available in this client: ${unavail.join(' ')}${RESET}`]);
593
+ try {
594
+ return await fn(controller.signal);
595
+ } finally {
596
+ if (timer) clearInterval(timer);
597
+ if (live) live.clear();
351
598
  }
352
- emit(['', render.renderNotice(ctx(), 'info', `plain text is a prompt ${GLYPH.bullet} /exit exits`)]);
353
599
  }
354
600
 
355
- /** Handle one line of input. Returns false when the session should end. */
356
- async function handleLine(line) {
601
+ async function showThemePicker() {
602
+ commandCtx.light = !commandCtx.light;
603
+ commandCtx.themeIndex = commandCtx.light ? 0 : 1;
604
+ emit(render.renderNotice(ctx(), 'ok', `theme: ${commandCtx.light ? 'light' : 'dark'}`));
605
+ }
606
+
607
+ /** Build the FROZEN command context `c` a handler runs against. */
608
+ function makeCommandContext() {
609
+ const c = {
610
+ ctx: commandCtx,
611
+ transcript,
612
+ push: (row) => pushRow(row),
613
+ note: (text) => pushRow({ role: 'note', text }),
614
+ panel: (lines) => pushRow({ role: 'panel', lines }),
615
+ render: () => {},
616
+ openOverlay: (o) => openOverlay(o),
617
+ closeOverlay: () => {},
618
+ askInput: () => Promise.resolve(null),
619
+ withWorking: (fn) => withWorking(fn),
620
+ runPrompt: (text) => runPrompt(text),
621
+ ask: (text) => ask(text),
622
+ runTool: (name, args) => runTool(name, args),
623
+ refreshSpend: () => refreshSpend(),
624
+ state: () => buildState(),
625
+ setInput: () => {},
626
+ exit: () => { wantExit = true; },
627
+ client,
628
+ TOOLS,
629
+ saveConfig: (patch) => updateConfig(patch),
630
+ showThemePicker: () => showThemePicker(),
631
+ };
632
+ Object.defineProperty(c, 'sessionId', {
633
+ enumerable: true,
634
+ get: () => commandCtx.sessionId,
635
+ set: (v) => { commandCtx.sessionId = v; },
636
+ });
637
+ return c;
638
+ }
639
+
640
+ /**
641
+ * Handle one line of input. Returns false when the session should end.
642
+ *
643
+ * `cOverride` lets the chatflow supply the context it owns (its own `push`,
644
+ * `render`, `openOverlay`, `askInput`, `runPrompt`) instead of the linear
645
+ * defaults — the handlers are identical either way, which is the point.
646
+ */
647
+ async function handleLine(line, cOverride = null) {
357
648
  const parsed = parseLine(line);
358
649
 
359
650
  if (parsed.kind === 'empty') return true;
@@ -374,7 +665,7 @@ function createApp(options = {}) {
374
665
  }
375
666
  if (parsed.kind === 'unavailable') {
376
667
  const c = parsed.command;
377
- emit(render.renderNotice(ctx(), 'warn', `/${c.name} is not available in aegiscode — ${c.why}`));
668
+ emit(render.renderNotice(ctx(), 'warn', `/${c.name} is not available in aegiscode — ${c.unavailable}`));
378
669
  if (c.alt) emit(render.renderNotice(ctx(), 'info', `try ${c.alt} instead`));
379
670
  else {
380
671
  const alt = nearest(c.name);
@@ -386,70 +677,16 @@ function createApp(options = {}) {
386
677
  const cmd = parsed.command;
387
678
  const arg = parsed.arg;
388
679
 
389
- if (cmd.local) {
390
- switch (cmd.local) {
391
- case 'exit':
392
- return false;
393
- case 'clear':
394
- out.write(EC.clearScreen);
395
- // aegiscodex-dev's /clear starts a new session with empty context, so
396
- // the session tallies reset too (the transcript is not persisted here).
397
- session.turns = 0;
398
- session.calls = 0;
399
- session.tokens = 0;
400
- session.inputTokens = 0;
401
- session.outputTokens = 0;
402
- session.cost = 0;
403
- session.startedAt = Date.now();
404
- emit(bannerLines());
405
- return true;
406
- case 'help':
407
- printHelp();
408
- return true;
409
- case 'version':
410
- emit(render.renderNotice(ctx(), 'info', `aegiscode v${VERSION}`));
411
- return true;
412
- case 'model':
413
- if (!arg) {
414
- emit(render.renderNotice(ctx(), 'info', `model: ${opts.model || 'server default'}`));
415
- } else if (arg === '-') {
416
- opts.model = null;
417
- emit(render.renderNotice(ctx(), 'ok', 'model pin cleared — the server will choose'));
418
- } else {
419
- opts.model = arg;
420
- emit(render.renderNotice(ctx(), 'ok', `pinned model: ${arg}`));
421
- }
422
- return true;
423
- case 'stream':
424
- opts.stream = arg ? !/^off|false|0$/i.test(arg) : !opts.stream;
425
- emit(render.renderNotice(ctx(), 'ok', `streaming ${opts.stream ? 'on' : 'off'}`));
426
- return true;
427
- case 'theme':
428
- opts.light = arg ? /^light/i.test(arg) : !opts.light;
429
- emit(render.renderNotice(ctx(), 'ok', `theme: ${opts.light ? 'light' : 'dark'}`));
430
- return true;
431
- case 'cost':
432
- case 'tokens': {
433
- const t = themeOf(ctx());
434
- emit([render.renderHeading(ctx(), 'session', width())]);
435
- emit([
436
- ` tokens ${t.white}${fmtTokens(session.tokens)}${RESET} ` +
437
- t.gray + `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out)` + RESET,
438
- ` spend ${t.green}${fmtEur(session.cost)}${RESET}`,
439
- ` calls ${session.calls}`,
440
- ` balance ${session.balance == null ? 'unknown' : fmtEur(session.balance)}`,
441
- ` elapsed ${fmtElapsed(Date.now() - session.startedAt)}`,
442
- ]);
443
- return true;
444
- }
445
- default:
446
- emit(render.renderNotice(ctx(), 'error', `/${cmd.name} is not implemented`));
447
- return true;
448
- }
449
- }
450
-
680
+ // The generic escape hatch (/tool <name> [json]).
451
681
  if (cmd.generic) {
452
- const { tool, args } = cmd.build(arg);
682
+ let built;
683
+ try {
684
+ built = cmd.build(arg);
685
+ } catch (e) {
686
+ emit(render.renderNotice(ctx(), 'error', e.message));
687
+ return true;
688
+ }
689
+ const { tool, args } = built;
453
690
  if (!tool) {
454
691
  emit(render.renderNotice(ctx(), 'error', '/tool needs a tool name — see /help'));
455
692
  return true;
@@ -462,6 +699,19 @@ function createApp(options = {}) {
462
699
  return true;
463
700
  }
464
701
 
702
+ // Handler-backed (the reference aegiscode-dev contract).
703
+ if (typeof cmd.handler === 'function') {
704
+ const args = parseArgs(cmd, arg);
705
+ const c = cOverride || makeCommandContext();
706
+ try {
707
+ const keep = await cmd.handler(c, args);
708
+ return keep === false ? false : true;
709
+ } catch (e) {
710
+ emit(render.renderNotice(ctx(), 'error', e && e.message ? e.message : String(e)));
711
+ return true;
712
+ }
713
+ }
714
+
465
715
  // Tool-backed command.
466
716
  try {
467
717
  let args = cmd.build(arg);
@@ -562,8 +812,87 @@ function createApp(options = {}) {
562
812
  return res.interrupted ? 130 : 0;
563
813
  }
564
814
 
565
- /** Interactive REPL. */
566
- async function runInteractive() {
815
+ /** Fold one completed turn's usage into the session tallies.
816
+ * @returns {number|null} the total token count for the turn, when known. */
817
+ function recordTurn(res) {
818
+ session.turns++;
819
+ session.calls += Number((res && res.calls) || 1) || 1;
820
+ const usage = res && res.usage;
821
+ const tokens = usageTokens(usage);
822
+ if (tokens != null) {
823
+ session.tokens += tokens;
824
+ session.inputTokens += Number(usage.input_tokens ?? usage.prompt_tokens ?? 0) || 0;
825
+ session.outputTokens += Number(usage.output_tokens ?? usage.completion_tokens ?? 0) || 0;
826
+ }
827
+ return tokens;
828
+ }
829
+
830
+ /** One line of session accounting, for ctrl+t and the meta row. */
831
+ function tokenSummary() {
832
+ return (
833
+ `${fmtTokens(session.tokens)} tok ` +
834
+ `(${fmtTokens(session.inputTokens)} in / ${fmtTokens(session.outputTokens)} out) · ` +
835
+ `${session.calls} call${session.calls === 1 ? '' : 's'} · ${fmtEur(session.cost)}` +
836
+ (session.balance == null ? '' : ` · balance ${fmtEur(session.balance)}`)
837
+ );
838
+ }
839
+
840
+ /** Load a stored session back into the live transcript. */
841
+ async function resumeSession(item) {
842
+ if (!item || !item.id) return;
843
+ const rows = readSessionTranscript(item.id);
844
+ if (!rows.length) {
845
+ emit(render.renderNotice(ctx(), 'warn', `no stored turns for ${item.id}`));
846
+ return;
847
+ }
848
+ // Replace, rather than append: "resume" means continue THIS conversation,
849
+ // and appending would put two sessions' turns in one context window.
850
+ transcript.length = 0;
851
+ for (const r of rows) transcript.push(r);
852
+ commandCtx.sessionId = item.id;
853
+ emit(render.renderNotice(ctx(), 'ok', `resumed ${item.id} — ${rows.length} turn(s)`));
854
+ }
855
+
856
+ /**
857
+ * The host the chatflow drives. Everything the loop needs from the app, with
858
+ * no layering of its own — the loop owns the frame and the keys, this owns
859
+ * the transport, the tools, the command table and the tallies.
860
+ */
861
+ function makeHost() {
862
+ return {
863
+ ctx: commandCtx,
864
+ version: VERSION,
865
+ transcript,
866
+ session,
867
+ client,
868
+ TOOLS,
869
+ ask: (prompt, o) => ask(prompt, o),
870
+ makeCommandContext: () => makeCommandContext(),
871
+ buildState: () => buildState(),
872
+ dispatchLine: (line, c) => handleLine(line, c),
873
+ refreshSpend: () => refreshSpend(),
874
+ updateConfig: (patch) => updateConfig(patch),
875
+ visibleCommands: () => visibleCommands(),
876
+ tokensFor: (usage) => usageTokens(usage),
877
+ recordTurn: (res) => recordTurn(res),
878
+ tokenSummary: () => tokenSummary(),
879
+ resumeSession: (item) => resumeSession(item),
880
+ requestExit: () => {
881
+ wantExit = true;
882
+ },
883
+ wantsExit: () => wantExit,
884
+ isYolo: () => {
885
+ try {
886
+ return loadPermissions().defaultMode === 'allow';
887
+ } catch {
888
+ return false;
889
+ }
890
+ },
891
+ };
892
+ }
893
+
894
+ /** The plain (non-alt-screen) REPL: readline, one turn written to scrollback. */
895
+ async function runLinearRepl() {
567
896
  emit(bannerLines());
568
897
  await refreshSpend();
569
898
  out.write('\n');
@@ -582,7 +911,7 @@ function createApp(options = {}) {
582
911
  } catch (e) {
583
912
  emit(render.renderNotice(ctx(), 'error', e.message));
584
913
  }
585
- if (!keep) {
914
+ if (!keep || wantExit) {
586
915
  closed = true;
587
916
  rl.close();
588
917
  return;
@@ -597,21 +926,50 @@ function createApp(options = {}) {
597
926
  });
598
927
  }
599
928
 
929
+ /**
930
+ * Interactive entry point. On a real terminal this is the full chatflow
931
+ * (alternate screen, header, transcript viewport, spinner, effort line,
932
+ * input line, status line, overlays). Anywhere else — a pipe, a test with an
933
+ * injected readline, `--print` — it stays the linear loop, so output remains
934
+ * pipeable and scriptable.
935
+ */
936
+ async function runInteractive() {
937
+ const tty =
938
+ !options.readline &&
939
+ options.chatflow !== false &&
940
+ process.stdin.isTTY &&
941
+ process.stdout.isTTY;
942
+ if (tty) {
943
+ await chatflow.runSession(makeHost());
944
+ return 0;
945
+ }
946
+ return runLinearRepl();
947
+ }
948
+
600
949
  return {
601
950
  opts,
602
951
  session,
603
952
  client,
604
953
  TOOLS,
605
954
  toolList,
955
+ ctx: commandCtx,
956
+ transcript,
606
957
  ask,
607
958
  runPrompt,
608
959
  handleLine,
609
960
  runOnce,
610
961
  runInteractive,
962
+ runLinearRepl,
611
963
  refreshSpend,
612
964
  bannerLines,
965
+ makeHost,
966
+ recordTurn,
967
+ tokenSummary,
968
+ resumeSession,
969
+ makeCommandContext,
970
+ buildState,
613
971
  get aborted() {
614
- return abortController != null;
972
+ return false;
615
973
  },
616
974
  };
617
975
  }