aegiscode 6.2.0 → 6.3.1

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/commands.js CHANGED
@@ -52,6 +52,7 @@ const {
52
52
  } = require('./config.js');
53
53
  const { copyToClipboard } = require('./clipboard.js');
54
54
  const { snapshotCheckpoint, listCheckpoints, loadCheckpoint } = require('./checkpoint.js');
55
+ const screens = require('./screens.js');
55
56
  const { summarizeTranscript, recapLine } = require('./summarize.js');
56
57
  const { sessionAccounting, accountingFromUsage, estimateTokens } = require('./tokens.js');
57
58
  const { transcriptToMarkdown, transcriptToJSON, writeExportFile, lastAssistantText } = require('./export.js');
@@ -102,6 +103,19 @@ const tip = (c, text) => c.push({ role: 'tip', text });
102
103
  const done = (c, text) => c.push({ role: 'done', text });
103
104
  const shortCwd = () => process.cwd().split('/').filter(Boolean).pop() || '~';
104
105
 
106
+ /**
107
+ * Refresh the pinnable-model list on the session context before reading it.
108
+ * `c.loadModels()` (owned by the app/chatflow) fetches from the server and
109
+ * records the result on `c.state().models`; it is best-effort — offline it
110
+ * rejects, and the caller falls through to the honest empty note rather than
111
+ * opening an empty picker.
112
+ */
113
+ async function loadModels(c) {
114
+ try {
115
+ await c.loadModels();
116
+ } catch {}
117
+ }
118
+
105
119
  /**
106
120
  * The ÆGIS LLM routes are gated on the pooled brain being reachable — exactly
107
121
  * as the reference hides its ÆGIS routes when the backend is absent. `hidden`
@@ -190,13 +204,17 @@ const COMMANDS = [
190
204
  c.render();
191
205
  return true;
192
206
  }
193
- note(c, `Running ${cmd} in ${shortCwd()} — output streams below.`);
194
- c.push({ role: 'tool', label: 'Bash', args: cmd });
207
+ note(c, `Running ${cmd} in ${shortCwd()} — output streams below, Esc stops it.`);
195
208
  const job = runDevServer(cmd, {
196
209
  cwd: process.cwd(),
197
210
  onLine: (line) => c.push({ role: 'note', text: ` ${line}` }),
198
211
  });
212
+ // Bind the job to the session so the status line draws "esc to stop" and
213
+ // Esc actually stops it (the reference's openStream/closeStream contract).
214
+ if (!job.label) job.label = cmd;
215
+ c.openStream(job);
199
216
  job.done.then(({ code, stopped }) => {
217
+ c.closeStream(job);
200
218
  done(c, stopped ? `${cmd} stopped` : `${cmd} exited (code ${code})`);
201
219
  c.render();
202
220
  });
@@ -290,7 +308,7 @@ const COMMANDS = [
290
308
  c.render();
291
309
  const summary = await c.withWorking((signal) =>
292
310
  summarizeTranscript(c.transcript, {
293
- callModel: (p) => c.ask(p).then((r) => r.text),
311
+ callModel: (p, opts) => c.ask(p, opts).then((r) => r.text),
294
312
  model: c.ctx.model,
295
313
  signal,
296
314
  }));
@@ -375,7 +393,12 @@ const COMMANDS = [
375
393
  : 'Clipboard unavailable; nothing copied.');
376
394
  } else {
377
395
  try {
378
- const p = writeExportFile(body, isJson ? 'json' : 'md');
396
+ // Honour the command context's cwd. The handler used to fall through
397
+ // to writeExportFile's own `process.cwd()` default, so /export wrote
398
+ // into whatever directory the process happened to be in — which meant
399
+ // running the command smoke test (it invokes every handler) littered
400
+ // the repository root with aegiscodex-export-*.md files.
401
+ const p = writeExportFile(body, isJson ? 'json' : 'md', c.ctx.cwd || process.cwd());
379
402
  note(c, `Exported conversation to ${p}`);
380
403
  } catch (e) {
381
404
  note(c, `/export: ${e.message}`);
@@ -399,9 +422,23 @@ const COMMANDS = [
399
422
  desc: 'Create an AEGIS.md file in the project',
400
423
  handler: async (c, args) => {
401
424
  const file = (args.file || 'AEGIS.md').trim();
402
- const p = path.join(process.cwd(), file);
425
+ // The command context's cwd, not process.cwd(): /cd moves the session to
426
+ // another directory, and a handler that reads process.cwd() then writes
427
+ // the file into the directory the user just left. It also means the
428
+ // command smoke test (which invokes every handler) no longer drops an
429
+ // AEGIS.md into the repository root.
430
+ const cwd = c.ctx.cwd || process.cwd();
431
+ const p = path.resolve(cwd, file);
432
+ // Refuse a path that resolves outside the project — the reference's /init
433
+ // takes no argument, and `path.join(cwd, '../../x')` writes outside it.
434
+ const root = cwd.endsWith(path.sep) ? cwd : cwd + path.sep;
435
+ if (p !== cwd && !p.startsWith(root)) {
436
+ note(c, `/init: refusing to write outside the project directory (${p}).`);
437
+ c.render();
438
+ return true;
439
+ }
403
440
  if (fs.existsSync(p)) { note(c, `${file} already exists — not overwriting.`); c.render(); return true; }
404
- const sniff = sniffProject(process.cwd());
441
+ const sniff = sniffProject(cwd);
405
442
  try {
406
443
  fs.writeFileSync(p, buildAegisMd(sniff) + '\n');
407
444
  } catch (e) {
@@ -410,7 +447,7 @@ const COMMANDS = [
410
447
  return true;
411
448
  }
412
449
  note(c, `Created ${p} (${sniff.lang})`);
413
- if (fs.existsSync(path.join(process.cwd(), 'node_modules')) && !fs.existsSync(path.join(process.cwd(), '.gitignore'))) {
450
+ if (fs.existsSync(path.join(cwd, 'node_modules')) && !fs.existsSync(path.join(cwd, '.gitignore'))) {
414
451
  tip(c, 'Add "node_modules/" to a .gitignore before committing.');
415
452
  }
416
453
  c.render();
@@ -435,10 +472,40 @@ const COMMANDS = [
435
472
  const id = (args.sub || '').trim();
436
473
  if (!id) {
437
474
  note(c, `model: ${c.ctx.model || 'server default'}`);
438
- c.openOverlay({ type: 'model', items: c.state().models || [], sel: 0, current: c.ctx.model });
475
+ // Populate the picker from the server first app.js's state().models
476
+ // is empty until loadModels() has run, so an unguarded read renders an
477
+ // empty picker whose Enter does nothing.
478
+ await loadModels(c);
479
+ const models = c.state().models || [];
480
+ if (!models.length) {
481
+ // Say why, and what unblocks it: an unreachable catalog is almost
482
+ // always a missing key or no network, and "no models advertised"
483
+ // read as "the platform has none" rather than "this client could not
484
+ // ask".
485
+ note(c, c.state().online
486
+ ? 'Could not read the model catalog (offline, or the server refused it) — /models retries.'
487
+ : // The key travels in the environment only (client/aegis.js reads
488
+ // AEGIS_API_KEY); /login is an unavailable command here, so
489
+ // pointing at it would send the user to a refusal.
490
+ 'No API key set, so the model catalog cannot be read — export AEGIS_API_KEY (free at https://aegiscloud.org), then retry /model.');
491
+ c.render();
492
+ return true;
493
+ }
494
+ // A pin that is not in the catalog is not honoured — the pool answers
495
+ // from its own default with no error. Say so where the pin is visible
496
+ // rather than letting the reply look like the pinned model.
497
+ if (c.ctx.model && !models.some((m) => m.id === c.ctx.model)) {
498
+ note(c, `pinned model "${c.ctx.model}" is not in the catalog — the pool will answer with its own default; pick one below.`);
499
+ }
500
+ // The overlay's own copy promises /model add|remove (overlays.js, a
501
+ // separate workstream); this build refuses both, so say here how a
502
+ // model is actually selected.
503
+ note(c, '/model <id> pins one for this session; /models lists what the server advertises.');
504
+ c.openOverlay({ type: 'model', items: models, sel: 0, current: c.ctx.model });
439
505
  return true;
440
506
  }
441
507
  if (id === 'list') {
508
+ await loadModels(c);
442
509
  const models = c.state().models || [];
443
510
  if (!models.length) note(c, 'No pinnable models advertised — /models lists the server\'s ids.');
444
511
  else panel(c, panels.buildModelList(c.ctx.model, models, c.ctx));
@@ -452,14 +519,28 @@ const COMMANDS = [
452
519
  }
453
520
  if (id === '-') {
454
521
  c.ctx.model = null;
455
- // Session-scoped pin only (matches the plugin's existing /model): this
456
- // never writes ~/.aegiscode/config.json, so a test run stays hermetic.
522
+ c.saveConfig({ model: null, currentModelId: null });
457
523
  note(c, 'Model pin cleared — the server will choose.');
458
524
  c.render();
459
525
  return true;
460
526
  }
461
527
  c.ctx.model = id;
528
+ // Persist, so the next launch restores it. app.js's restorePrefs() reads
529
+ // this on startup; without the write it had nothing to restore and the
530
+ // pin silently evaporated at exit. Tests point AEGISCODE_HOME at a temp
531
+ // dir, so the write stays inside that dir.
532
+ c.saveConfig({ model: id, currentModelId: id });
462
533
  note(c, `Pinned model: ${id}`);
534
+ // The server accepts an id it does not advertise and answers from its own
535
+ // default — no error, a different model, and the spend attributed to the
536
+ // id that was pinned. Warn (never refuse: the catalog is cached, and
537
+ // refusing would break a pin made against a server that is briefly
538
+ // unreachable) so the mismatch is visible at the moment it is created.
539
+ await loadModels(c);
540
+ const models = c.state().models || [];
541
+ if (models.length && !models.some((m) => m.id === id)) {
542
+ note(c, `"${id}" is not in the AEGIS Cloud catalog — the pool will answer with its own default. /models lists the real ids.`);
543
+ }
463
544
  c.render();
464
545
  return true;
465
546
  },
@@ -487,7 +568,7 @@ const COMMANDS = [
487
568
  c.render();
488
569
  const line = await c.withWorking((signal) =>
489
570
  recapLine(c.transcript, {
490
- callModel: (p) => c.ask(p).then((r) => r.text),
571
+ callModel: (p, opts) => c.ask(p, opts).then((r) => r.text),
491
572
  model: c.ctx.model,
492
573
  signal,
493
574
  }));
@@ -580,8 +661,13 @@ const COMMANDS = [
580
661
  handler: async (c, args) => {
581
662
  const m = (args.mode || '').toLowerCase();
582
663
  if (m === 'dark' || m === 'light') {
583
- c.ctx.light = m === 'light';
584
- c.ctx.themeIndex = c.ctx.light ? 0 : 1;
664
+ // Resolve through the theme table rather than hardcoding an index, so
665
+ // "light" names the same row the picker would have named: index 2
666
+ // ("Light mode"), not index 0 ("Auto"). themeIndex is what the
667
+ // colorblind/ANSI palettes key off, so a wrong index silently selects
668
+ // the wrong palette rather than merely displaying a wrong label.
669
+ const want = m === 'light' ? 2 : 1;
670
+ screens.applyTheme(c.ctx, want);
585
671
  } else {
586
672
  await c.showThemePicker();
587
673
  }
package/src/config.js CHANGED
@@ -43,7 +43,17 @@ function permissionsPath() {
43
43
 
44
44
  const DEFAULT_CONFIG = {
45
45
  themeIndex: 1, // Dark mode
46
- model: 'sonnet',
46
+ // No pinned model. This host runs on AEGIS Cloud, whose pinnable ids are the
47
+ // server's (`/models`) — a client-side default here would have to name one,
48
+ // and the one it named (`sonnet`) is not advertised by the platform at all:
49
+ // the pool accepts an unknown id and answers from its own default with no
50
+ // error, so the pin looked honoured while the reply came from another model.
51
+ // Worse, onboarding persists this object on first run (`updateConfig` merges
52
+ // DEFAULT_CONFIG under the patch), so the phantom pin was written to disk for
53
+ // every user who ever completed the trust check. `null` = no pin; the server
54
+ // chooses, and app.js's validatePinnedModel() clears a stored id the catalog
55
+ // does not advertise.
56
+ model: null,
47
57
  // Phase 6: the full model table (seeded from src/models.js MODELS on first
48
58
  // read by pickerModels()). 'currentModelId' mirrors `model` under the
49
59
  // aegiscode- name so /model add/remove/switch stay compatible both ways.
package/src/events.js CHANGED
@@ -249,6 +249,36 @@ function drainQueue() {
249
249
  }
250
250
 
251
251
  /** Test seam: forget attached stdin, timers and queued keys. */
252
+ /** True once a key stream is attached — i.e. whether nextKey() has a source. */
253
+ function isKeyStreamAttached() {
254
+ return !!attachedStdin;
255
+ }
256
+
257
+ /**
258
+ * Tear the pump down, removing the data listener.
259
+ *
260
+ * `resetKeyStream()` deliberately only *drops the reference* (it is used
261
+ * between tests and after a child-process handover), which leaves the listener
262
+ * registered on stdin. Attaching again would then run two pumps over the same
263
+ * bytes and dispatch every keystroke twice. Onboarding attaches before the
264
+ * session loop does, so it needs a real detach rather than a reset.
265
+ */
266
+ function detachKeyStream() {
267
+ if (attachedStdin && onData) {
268
+ try {
269
+ attachedStdin.removeListener('data', onData);
270
+ } catch {
271
+ /* stream already gone */
272
+ }
273
+ try {
274
+ if (typeof attachedStdin.setRawMode === 'function') attachedStdin.setRawMode(false);
275
+ } catch {
276
+ /* not a TTY */
277
+ }
278
+ }
279
+ resetKeyStream();
280
+ }
281
+
252
282
  function resetKeyStream() {
253
283
  clearTimeout(escTimer);
254
284
  clearTimeout(pasteTimer);
@@ -270,6 +300,8 @@ module.exports = {
270
300
  suspendKeyStream,
271
301
  resumeKeyStream,
272
302
  isKeyStreamSuspended,
303
+ isKeyStreamAttached,
304
+ detachKeyStream,
273
305
  nextKey,
274
306
  nextKeyTimeout,
275
307
  requeueKeys,
package/src/models.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AEGIS Cloud model catalog shaping — the one place `/model`, the alt+p picker
5
+ * and `/models` agree on what a pinnable model is.
6
+ *
7
+ * The ids a user may pin are the *server's*, never ours: the pool adds, renames
8
+ * and retires providers without a client release, so the catalog is fetched
9
+ * (`client.listModels()` → GET /api/v1/models on aegiscloud.org) and this module
10
+ * only normalises the payload. It deliberately invents no id, and has no
11
+ * fallback list: an offline client offers nothing rather than a model name that
12
+ * would silently route somewhere else.
13
+ *
14
+ * The platform lists per-provider ids (`deepseek`, `anthropic`, `groq`, …) plus
15
+ * the pooled-brain tiers. The desktop host collapsed all of that to a single
16
+ * "Nexus" entry because its dropdown *is* the model choice and surfacing
17
+ * per-provider routing invites pinning a provider that happens to be dead right
18
+ * now (see desktop/lib/local/engine.js selectBrainEntry). This host keeps every
19
+ * distinct id — a CLI user pinning `deepseek` explicitly is a legitimate
20
+ * request — but drops the pure-alias duplicates the server itself marks
21
+ * `hidden: true, alias_of: "<id>"` (`aegis-brain`, `nexus-brain-smart`, …),
22
+ * which are the same model advertised under another spelling.
23
+ */
24
+
25
+ /** A pinned id of `null` means "no pin — let the pool choose" (the server's own default). */
26
+ const NO_PIN = null;
27
+
28
+ /**
29
+ * Labels for catalog ids the platform advertises without capabilities or a
30
+ * label of their own — `openai-gpt4o-mini` reads like a typo next to `openai`,
31
+ * and the brain tier is the one id whose *cost shape* a user needs before
32
+ * pinning it: verified live 2026-09-14, `model: "nexus-brain"` streams
33
+ * `pool-brain: 3 workers · effort=high · tier=brain · passes=4`, i.e. four
34
+ * billed provider calls per turn, where a provider id is one.
35
+ */
36
+ const ID_NOTES = Object.freeze({
37
+ 'openai-gpt4o-mini': 'OpenAI gpt-4o-mini, pooled',
38
+ 'anthropic-haiku': 'Anthropic Haiku, pooled',
39
+ 'nexus-brain': 'pooled brain · 3 workers + synthesis',
40
+ });
41
+
42
+ /** One raw entry (string id or object) → a catalog entry, or null when unusable. */
43
+ function normalizeModel(entry) {
44
+ const m = typeof entry === 'string' ? { id: entry } : entry;
45
+ if (!m || typeof m !== 'object') return null;
46
+ const id = typeof m.id === 'string' ? m.id.trim() : '';
47
+ if (!id) return null;
48
+ const aliasOf =
49
+ typeof m.alias_of === 'string' && m.alias_of.trim() ? m.alias_of.trim() : null;
50
+ const capabilities = Array.isArray(m.capabilities)
51
+ ? m.capabilities.filter((c) => typeof c === 'string' && c)
52
+ : [];
53
+ const label =
54
+ (typeof m.label === 'string' && m.label.trim()) ||
55
+ (typeof m.name === 'string' && m.name.trim()) ||
56
+ id;
57
+ // The picker's right-hand column: what this entry *is*. An alias says so —
58
+ // otherwise a user reads five brain tiers and assumes five different models.
59
+ const note = aliasOf
60
+ ? `alias of ${aliasOf}`
61
+ : ID_NOTES[id] || capabilities.join(', ');
62
+ return {
63
+ id,
64
+ label,
65
+ note,
66
+ hidden: m.hidden === true,
67
+ aliasOf,
68
+ capabilities,
69
+ };
70
+ }
71
+
72
+ /** Normalise a raw `/api/v1/models` payload (`{models: [...]}` or the array). */
73
+ function normalizeModelCatalog(raw) {
74
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw && raw.models) ? raw.models : [];
75
+ const out = [];
76
+ const seen = new Set();
77
+ for (const entry of list) {
78
+ const m = normalizeModel(entry);
79
+ if (!m || seen.has(m.id)) continue;
80
+ seen.add(m.id);
81
+ out.push(m);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * The entries the picker and `/models` offer: every distinct advertised model,
88
+ * minus the alias duplicates — unless the catalog is *only* aliases, in which
89
+ * case the aliases are all the server advertises and are offered rather than
90
+ * leaving the user with an empty list.
91
+ */
92
+ function pickerEntries(catalog) {
93
+ const all = Array.isArray(catalog) ? catalog.filter(Boolean) : [];
94
+ const distinct = all.filter((m) => !m.aliasOf);
95
+ return distinct.length ? distinct : all;
96
+ }
97
+
98
+ /** The catalog entry for `id`, or null — the "is this a real id?" question. */
99
+ function findModel(catalog, id) {
100
+ const want = typeof id === 'string' ? id.trim() : '';
101
+ if (!want) return null;
102
+ return (Array.isArray(catalog) ? catalog : []).find((m) => m && m.id === want) || null;
103
+ }
104
+
105
+ /**
106
+ * The set of ids the server accepts, lower-cased: a catalog id and a pinned id
107
+ * are the same thing only when they match byte-for-byte, but `/model Nexus-Brain`
108
+ * is a typo a user will make and the server's routing is case-insensitive
109
+ * enough that warning about it would be noise.
110
+ */
111
+ function catalogIds(catalog) {
112
+ return new Set((Array.isArray(catalog) ? catalog : []).map((m) => String((m && m.id) || '').toLowerCase()).filter(Boolean));
113
+ }
114
+
115
+ module.exports = {
116
+ NO_PIN,
117
+ ID_NOTES,
118
+ normalizeModel,
119
+ normalizeModelCatalog,
120
+ pickerEntries,
121
+ findModel,
122
+ catalogIds,
123
+ };
package/src/render.js CHANGED
@@ -416,6 +416,10 @@ module.exports = {
416
416
  renderNotice,
417
417
  mdLines,
418
418
  inline,
419
+ // Welcome-mark pieces, shared with the onboarding screens so there is exactly
420
+ // one renderer for the mascot/moon/whale mark rather than two that can drift.
421
+ artRow,
422
+ tintWhale,
419
423
  // Style helpers kept for legacy consumers.
420
424
  fg,
421
425
  bg,