aegiscode 6.5.0 → 6.5.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.5.0",
4
+ "version": "6.5.2",
5
5
  "description": "aegiscode \u2014 the command-line version of AEGIS Desktop. The shared tool surface in your shell, over the same thin transport and tool registry as the MCP plugin and the desktop app. Ships transport + UI only; no brain.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/src/app.js CHANGED
@@ -1007,9 +1007,15 @@ function createApp(options = {}) {
1007
1007
  * Best-effort by construction: the two writers swallow their own failures, and
1008
1008
  * accounting must never be able to break a turn.
1009
1009
  */
1010
- function persistTurn(prompt, res, status = 'done') {
1010
+ function persistTurn(prompt, res, status = 'done', costEur = null) {
1011
1011
  try {
1012
1012
  const usage = res && res.usage;
1013
+ // What the POOL charged, straight from the ledger — margin and
1014
+ // prompt-cache discount already applied. Stored so /cost can report the
1015
+ // real bill instead of recomputing from provider rates it cannot see.
1016
+ // (The history field is historically named `costUsd`; the value here is
1017
+ // EUR, which is what every surface in this client displays.)
1018
+ const settled = typeof costEur === 'number' && Number.isFinite(costEur) ? costEur : null;
1013
1019
  appendHistory({
1014
1020
  sessionId: commandCtx.sessionId,
1015
1021
  prompt,
@@ -1021,8 +1027,11 @@ function createApp(options = {}) {
1021
1027
  output: Number(usage.output_tokens ?? usage.completion_tokens ?? 0) || 0,
1022
1028
  cacheRead: Number(usage.cache_read_input_tokens ?? 0) || 0,
1023
1029
  cacheWrite: Number(usage.cache_creation_input_tokens ?? 0) || 0,
1030
+ ...(settled != null ? { costUsd: settled } : {}),
1024
1031
  }
1025
- : null,
1032
+ : settled != null
1033
+ ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: settled }
1034
+ : null,
1026
1035
  });
1027
1036
  snapshotCheckpoint(commandCtx.sessionId, transcript);
1028
1037
  } catch {
package/src/chatflow.js CHANGED
@@ -66,11 +66,28 @@ const FRAME_MS = 33; // ~30fps cap for streaming repaints
66
66
 
67
67
  // ── pure turn helpers ───────────────────────────────────────────────────────
68
68
 
69
+ /**
70
+ * The shipped engine's shell tool is `exec` and its subagent tool is `task`
71
+ * (desktop/lib/local/tools.js). `Bash`/`Task` are Claude Code's names for the
72
+ * same two capabilities — this file was written against the reference's
73
+ * registry, so every branch below keyed on the reference's spelling and missed
74
+ * the tools that actually arrive: a shell row read "exec command" instead of
75
+ * "shell command", and a subagent row read "task command". Both spellings are
76
+ * accepted so either registry renders identically.
77
+ */
78
+ function isShellTool(name) {
79
+ return name === 'exec' || name === 'Bash';
80
+ }
81
+
82
+ function isAgentTool(name) {
83
+ return name === 'task' || name === 'Task';
84
+ }
85
+
69
86
  /** The noun a tool row uses: "shell command", "subagent", "read command"… */
70
87
  function toolLabel(name, n) {
71
88
  const base =
72
- name === 'Bash' ? 'shell command'
73
- : name === 'Task' ? 'subagent'
89
+ isShellTool(name) ? 'shell command'
90
+ : isAgentTool(name) ? 'subagent'
74
91
  : `${String(name).toLowerCase()} command`;
75
92
  return n > 1 ? base + 's' : base;
76
93
  }
@@ -120,14 +137,22 @@ function resolveToolDone(transcript, t) {
120
137
  * fall back to "(no response)" for empty text. An abort wins over an error so
121
138
  * Esc-cancel never produces the doubled marker.
122
139
  */
123
- function finalizeTurnText(text, { aborted, error } = {}) {
140
+ function finalizeTurnText(text, { aborted, error, reasoned } = {}) {
124
141
  let t = String(text == null ? '' : text);
125
142
  if (error && !aborted && error !== 'stopped') {
126
143
  const err = `(backend error: ${error})`;
127
144
  t = t.trim() ? `${t.trimEnd()}\n\n${err}` : err;
128
145
  }
129
146
  if (aborted) t = t.trim() ? `${t.trimEnd()} (stopped)` : '(stopped)';
130
- if (!t.trim()) t = '(no response)';
147
+ if (!t.trim()) {
148
+ // A reasoning model that spends its whole token budget on hidden
149
+ // chain-of-thought finishes with empty content and no error. "(no
150
+ // response)" reads as a broken client for what is really an exhausted
151
+ // budget, and sends you looking for a bug that is not there.
152
+ t = reasoned
153
+ ? '(no answer — the model used its whole budget on hidden reasoning before writing one; retry, or raise the budget with /effort)'
154
+ : '(no response)';
155
+ }
131
156
  return t;
132
157
  }
133
158
 
@@ -234,10 +259,15 @@ function confirmLines(overlay, cols, ctx) {
234
259
  const t = themeOf(ctx);
235
260
  const lines = [];
236
261
  lines.push([span(t.gray, '─'.repeat(Math.min(Math.max(10, cols - 4), 80)))]);
237
- lines.push([span(t.white, `${overlay.name} command`)]);
262
+ lines.push([span(t.white, `${isShellTool(overlay.name) ? 'shell' : overlay.name} command`)]);
238
263
  lines.push([span('', '')]);
264
+ // `exec` carries its subject in `command`; readFile/writeFile/editFile in
265
+ // `file_path`; glob/grep in `pattern`. Keying this on the reference's `Bash`
266
+ // sent every shell approval into the file branch, so the dialog that asks
267
+ // "Do you want to proceed?" showed an empty body — you were approving a
268
+ // command you could not see.
239
269
  const subject =
240
- overlay.name === 'Bash'
270
+ isShellTool(overlay.name)
241
271
  ? String((overlay.args && overlay.args.command) || '')
242
272
  : String((overlay.args && (overlay.args.file_path || overlay.args.pattern)) || '');
243
273
  for (const l of wrapBlock(subject, Math.max(8, cols - 4))) lines.push([span(t.gray, l)]);
@@ -255,6 +285,38 @@ function confirmLines(overlay, cols, ctx) {
255
285
  return lines;
256
286
  }
257
287
 
288
+ /**
289
+ * The accounting bits for a turn — model, tokens, the in/out split, €, elapsed
290
+ * and call count. Shared by the standalone `meta` row and the folded footer, so
291
+ * the two can never drift apart.
292
+ *
293
+ * `omitMs` drops the client-call time: when the bits ride on the `✻ Churned for
294
+ * 6s` row, that row already states the wall time, and printing a second,
295
+ * slightly different duration beside it reads as a discrepancy.
296
+ */
297
+ function metaBits(m, t, { omitMs = false } = {}) {
298
+ const bits = [];
299
+ if (m.model) bits.push(span(t.blue, m.model));
300
+ if (m.tokens != null) bits.push(span(t.white, `${fmtTokens(m.tokens)} tok`));
301
+ if (m.input != null || m.output != null) {
302
+ bits.push(span(t.gray, `${fmtTokens(m.input || 0)}/${fmtTokens(m.output || 0)}`));
303
+ }
304
+ if (m.eur != null) bits.push(span(m.eur > 0 ? t.coral : t.green, fmtEur(m.eur)));
305
+ if (m.ms != null && !omitMs) bits.push(span(t.gray, fmtElapsed(m.ms)));
306
+ if (m.calls > 1) bits.push(span(t.gray, `${m.calls} calls`));
307
+ return bits;
308
+ }
309
+
310
+ /** Interleave accounting bits with the `·` separator. */
311
+ function joinBits(bits, t) {
312
+ const line = [];
313
+ bits.forEach((b, i) => {
314
+ if (i) line.push(span(t.dim, ` ${GLYPH.bullet} `));
315
+ line.push(b);
316
+ });
317
+ return line;
318
+ }
319
+
258
320
  /** Render one transcript row to span lines. */
259
321
  function rowLines(msg, cols, ctx, now = Date.now()) {
260
322
  const t = themeOf(ctx);
@@ -285,32 +347,24 @@ function rowLines(msg, cols, ctx, now = Date.now()) {
285
347
  return out;
286
348
  }
287
349
  if (msg.role === 'done') {
288
- // Real 2.1.211: "✻ Churned for 6s" — bloom glyph + gray text.
289
- out.push([span(t.gray, GLYPH.bloom), span(t.gray, ` ${msg.text}`)]);
350
+ // Real 2.1.211: "✻ Churned for 6s" — bloom glyph + gray text. The turn's
351
+ // accounting rides on this same row (see the turn's finally block): one
352
+ // footer per turn instead of a done row plus a second `⎿` row beneath it.
353
+ const line = [span(t.gray, GLYPH.bloom), span(t.gray, ` ${msg.text}`)];
354
+ if (msg.meta) {
355
+ const bits = metaBits(msg.meta, t, { omitMs: true });
356
+ if (bits.length) line.push(span(t.dim, ' '), ...joinBits(bits, t));
357
+ }
358
+ out.push(line);
290
359
  return out;
291
360
  }
292
361
  if (msg.role === 'meta') {
293
362
  // Deliberate divergence from the reference: this client's reason to exist
294
363
  // is showing what a turn consumed, so the accounting line is a transcript
295
- // row rather than something only /cost can reveal.
296
- const m = msg.meta || {};
297
- const bits = [];
298
- if (m.model) bits.push(span(t.blue, m.model));
299
- if (m.tokens != null) bits.push(span(t.white, `${fmtTokens(m.tokens)} tok`));
300
- if (m.input != null || m.output != null) {
301
- bits.push(span(t.gray, `${fmtTokens(m.input || 0)}/${fmtTokens(m.output || 0)}`));
302
- }
303
- if (m.eur != null) bits.push(span(m.eur > 0 ? t.coral : t.green, fmtEur(m.eur)));
304
- if (m.ms != null) bits.push(span(t.gray, fmtElapsed(m.ms)));
305
- if (m.calls > 1) bits.push(span(t.gray, `${m.calls} calls`));
306
- if (bits.length) {
307
- const line = [span(t.dim, `${GLYPH.hook} `)];
308
- bits.forEach((b, i) => {
309
- if (i) line.push(span(t.dim, ` ${GLYPH.bullet} `));
310
- line.push(b);
311
- });
312
- out.push(line);
313
- }
364
+ // row rather than something only /cost can reveal. Kept for rows that have
365
+ // no `done` row to fold into (and for hosts that emit it standalone).
366
+ const bits = metaBits(msg.meta || {}, t);
367
+ if (bits.length) out.push([span(t.dim, `${GLYPH.hook} `), ...joinBits(bits, t)]);
314
368
  return out;
315
369
  }
316
370
  if (msg.role === 'panel') {
@@ -463,7 +517,11 @@ function inputStart(buf, scroll) {
463
517
  */
464
518
  function inputLine(state, cols, ctx) {
465
519
  const t = themeOf(ctx);
466
- const line = [span(t.gray, GLYPH.cursor), span('', ' '), span('', ' ')];
520
+ // Two cells ("❯ "), matching the reference. The cursor columns below are all
521
+ // `3 + w(text before the caret)`, which is only correct if the typed text
522
+ // starts in cell 3 — a third prefix cell pushed the text to cell 4 and left
523
+ // the caret one cell short, i.e. sitting *on* the last typed character.
524
+ const line = [span(t.gray, GLYPH.cursor), span('', ' ')];
467
525
  if (state.inputPrompt) {
468
526
  line.push(span(t.white, `${state.inputPrompt.title}: `));
469
527
  for (const ch of [...state.inputPrompt.buf]) line.push(span(t.white, ch));
@@ -732,6 +790,7 @@ async function runSession(host) {
732
790
 
733
791
  push({ role: 'user', text: prompt });
734
792
  const msg = push({ role: 'assistant', text: '', streaming: true });
793
+ let sawReasoning = false;
735
794
 
736
795
  const presenter = {
737
796
  text: (delta) => {
@@ -739,7 +798,10 @@ async function runSession(host) {
739
798
  streamedChars += w(delta);
740
799
  scheduleRender();
741
800
  },
742
- reasoning: () => {},
801
+ // Not rendered (deliberation is not an answer), but recorded: a turn
802
+ // that reasoned and then produced nothing needs a different explanation
803
+ // from one that produced nothing at all.
804
+ reasoning: () => { sawReasoning = true; },
743
805
  tool: (tool) => {
744
806
  if (tool.phase === 'run') {
745
807
  const n = ++toolSeq;
@@ -776,14 +838,30 @@ async function runSession(host) {
776
838
  result = { error: (err && err.message) || String(err) };
777
839
  } finally {
778
840
  msg.streaming = false;
841
+ // The row is built from streamed deltas, but the transport is the
842
+ // authority on what the turn actually produced. A provider that emits
843
+ // no incremental content — a non-streaming fallback, a gateway that
844
+ // only sends the finished message, a reasoning model whose visible
845
+ // answer arrives in one final frame — left this row empty and the user
846
+ // read "(no response)" for a turn that was answered and billed.
847
+ if (!String(msg.text || '').trim() && result && typeof result.text === 'string' && result.text.trim()) {
848
+ msg.text = result.text;
849
+ }
779
850
  working = false;
780
851
  clearInterval(spinnerTimer);
781
852
  spinnerTimer = null;
782
853
  setTitle('AEGIS Code', false);
783
854
  const secs = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
784
855
  const abortedFlag = !!(abort && abort.signal.aborted);
785
- msg.text = finalizeTurnText(msg.text, { aborted: abortedFlag, error: result && result.error });
786
- push({ role: 'done', text: `${toolSeq > 0 ? DONE_VERBS[1] : DONE_VERBS[0]} for ${secs}s` }, { follow: false });
856
+ msg.text = finalizeTurnText(msg.text, {
857
+ aborted: abortedFlag,
858
+ error: result && result.error,
859
+ reasoned: sawReasoning,
860
+ });
861
+ const footer = push(
862
+ { role: 'done', text: `${toolSeq > 0 ? DONE_VERBS[1] : DONE_VERBS[0]} for ${secs}s` },
863
+ { follow: false }
864
+ );
787
865
  suggestionIdx = turnCount + 1;
788
866
  turnCount++;
789
867
  abort = null;
@@ -791,11 +869,12 @@ async function runSession(host) {
791
869
  // Accounting: fold the turn's usage into the session tallies, ask the
792
870
  // ledger what it settled at, and show both — tokens beside €.
793
871
  host.recordTurn(result);
794
- // Persist the finished exchange for /resume. Guarded: the test host stub
795
- // deliberately omits persistTurn, and the accounting below must still run.
796
- if (host.persistTurn) {
797
- host.persistTurn(prompt, result, abortedFlag ? 'stopped' : (result && result.error) ? 'error' : 'done');
798
- }
872
+ // Ask the ledger what this turn settled at BEFORE persisting it. Order
873
+ // matters: the charge is the server's number (margin and prompt-cache
874
+ // discount included) and only it can answer "what did that cost". The
875
+ // turn used to be written first, with no charge attached, which left
876
+ // /cost recomputing from a local rate table that knows about neither —
877
+ // so the figure a user read never matched the bill they paid.
799
878
  let lastCost = null;
800
879
  try {
801
880
  const spend = await host.refreshSpend();
@@ -803,6 +882,16 @@ async function runSession(host) {
803
882
  } catch {
804
883
  /* accounting must never break a turn */
805
884
  }
885
+ // Persist the finished exchange for /resume. Guarded: the test host stub
886
+ // deliberately omits persistTurn, and the accounting below must still run.
887
+ if (host.persistTurn) {
888
+ host.persistTurn(
889
+ prompt,
890
+ result,
891
+ abortedFlag ? 'stopped' : (result && result.error) ? 'error' : 'done',
892
+ lastCost
893
+ );
894
+ }
806
895
  const usage = result && result.usage;
807
896
  const tokens = host.tokensFor ? host.tokensFor(usage) : null;
808
897
  push(
package/src/config.js CHANGED
@@ -45,6 +45,10 @@ function permissionsPath() {
45
45
 
46
46
  const DEFAULT_CONFIG = {
47
47
  themeIndex: 1, // Dark mode
48
+ // Cached `{ checkedAt, latest }` from the once-a-day registry check (see
49
+ // src/update.js). Persisted so a launch reports what the LAST run learned
50
+ // and never waits on the network to draw its own welcome box.
51
+ updateCheck: null,
48
52
  // No pinned model. This host runs on AEGIS Cloud, whose pinnable ids are the
49
53
  // server's (`/models`) — a client-side default here would have to name one,
50
54
  // and the one it named (`sonnet`) is not advertised by the platform at all:
package/src/models.js CHANGED
@@ -31,12 +31,16 @@ const NO_PIN = null;
31
31
  * and the brain tier is the one id whose *cost shape* a user needs before
32
32
  * pinning it: verified live 2026-09-14, `model: "nexus-brain"` streams
33
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.
34
+ * billed provider calls per turn, where a provider id is one. That count is
35
+ * not fixed — the endpoint sizes the fan-out from the ask and the /effort rung
36
+ * (aegis1 services/pool_brain.py estimate_workers + EFFORT_MAX_WORKERS), so the
37
+ * label states the shape (fan-out, sized by effort) rather than a number that
38
+ * only held for one prompt.
35
39
  */
36
40
  const ID_NOTES = Object.freeze({
37
41
  'openai-gpt4o-mini': 'OpenAI gpt-4o-mini, pooled',
38
42
  'anthropic-haiku': 'Anthropic Haiku, pooled',
39
- 'nexus-brain': 'pooled brain · 3 workers + synthesis',
43
+ 'nexus-brain': 'pooled brain · multi-call fan-out, sized by /effort',
40
44
  });
41
45
 
42
46
  /** One raw entry (string id or object) → a catalog entry, or null when unusable. */
@@ -29,6 +29,40 @@
29
29
 
30
30
  const RULE_RE = /^(\w+)(?:\((.*)\))?$/;
31
31
 
32
+ /**
33
+ * Tool-name aliases. The shipped engine (desktop/lib/local/tools.js) names its
34
+ * tools readFile/writeFile/editFile/listDir/glob/grep/exec/task, while this
35
+ * module was ported from the reference and keyed on that CLI's names —
36
+ * Read/Write/Edit/Glob/Grep/Bash/Task. Those reference names are also what
37
+ * users write into /permissions, so both spellings must fold onto one
38
+ * capability. Keying on the reference's spelling alone meant subjectFor()
39
+ * returned '' for every tool the engine actually calls: `exec(git *)` never
40
+ * matched its subject, `Read(*.env)` never matched a readFile, and the
41
+ * multi-directory rail never fired — a permission system blind to the very
42
+ * tools it was meant to govern.
43
+ */
44
+ const TOOL_ALIAS = {
45
+ exec: 'shell', Bash: 'shell',
46
+ readFile: 'read', Read: 'read',
47
+ writeFile: 'write', Write: 'write',
48
+ editFile: 'edit', Edit: 'edit',
49
+ listDir: 'list', LS: 'list',
50
+ glob: 'glob', Glob: 'glob',
51
+ grep: 'grep', Grep: 'grep',
52
+ task: 'task', Task: 'task',
53
+ };
54
+
55
+ /** Fold either registry's name for a tool onto the capability it names. */
56
+ function canonTool(name) {
57
+ const key = String(name == null ? '' : name);
58
+ return TOOL_ALIAS[key] || key;
59
+ }
60
+
61
+ /** True for the engine's `exec` and the reference's `Bash` alike. */
62
+ function isShellTool(toolName) {
63
+ return canonTool(toolName) === 'shell';
64
+ }
65
+
32
66
  function globToRegex(pattern) {
33
67
  let re = '^';
34
68
  for (const c of pattern) {
@@ -37,23 +71,28 @@ function globToRegex(pattern) {
37
71
  else if (/[.+^${}()|[\]\\]/.test(c)) re += `\\${c}`;
38
72
  else re += c;
39
73
  }
40
- return new RegExp(re + '$');
74
+ return new RegExp(re + "$");
41
75
  }
42
76
 
43
77
  /** The subject a rule's pattern matches against, per tool. */
44
78
  function subjectFor(toolName, args = {}) {
45
- if (toolName === 'Bash') return String(args.command || '');
46
- if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') return String(args.file_path || '');
47
- if (toolName === 'Glob' || toolName === 'Grep') return String(args.pattern || '');
48
- return '';
79
+ switch (canonTool(toolName)) {
80
+ case 'shell': return String(args.command || '');
81
+ case 'read': case 'write': case 'edit': return String(args.file_path || '');
82
+ case 'glob': case 'grep': return String(args.pattern || '');
83
+ case 'list': return String(args.path || '');
84
+ default: return '';
85
+ }
49
86
  }
50
87
 
51
88
  function matchRule(rule, toolName, subject) {
52
89
  const m = RULE_RE.exec(String(rule || '').trim());
53
90
  if (!m) return false;
54
91
  const [, tool, pattern] = m;
55
- if (tool !== toolName) return false;
56
- if (pattern === undefined) return true; // bare "Bash" matches every call
92
+ // A rule spelled with the reference's name governs the engine's tool and
93
+ // vice versa: `Bash(git *)` and `exec(git *)` are one rule, not two.
94
+ if (canonTool(tool) !== canonTool(toolName)) return false;
95
+ if (pattern === undefined) return true; // bare "Bash"/"exec" matches every call
57
96
  try { return globToRegex(pattern).test(subject); } catch { return false; }
58
97
  }
59
98
 
@@ -90,13 +129,17 @@ function evalPermission(toolName, args, rules = {}) {
90
129
  const subject = subjectFor(toolName, args);
91
130
  if (matchesAny(rules.deny, toolName, subject)) return 'deny';
92
131
  if (matchesAny(rules.allow, toolName, subject)) return 'allow';
93
- if (toolName === 'Bash' && isMultiDirCommand(args && args.command)) return 'ask';
132
+ if (isShellTool(toolName) && isMultiDirCommand(args && args.command)) return 'ask';
94
133
  if (matchesAny(rules.ask, toolName, subject)) return 'ask';
95
134
  if (rules.explicitAsk && rules.defaultMode === 'ask') return 'ask';
96
135
  return 'allow';
97
136
  }
98
137
 
99
138
  module.exports = {
139
+ canonTool,
140
+ isShellTool,
141
+ subjectFor,
142
+ matchRule,
100
143
  isMultiDirCommand,
101
144
  evalPermission,
102
145
  };
package/src/render.js CHANGED
@@ -279,11 +279,14 @@ function renderMeta(ctx, meta = {}, width = 80) {
279
279
  const bits = [];
280
280
  if (meta.model) bits.push(`${t.blue}${meta.model}${RESET}`);
281
281
  if (meta.tokens != null) bits.push(`${t.white}${fmtTokens(meta.tokens)} tok${RESET}`);
282
- if (meta.usage) {
283
- const { input, output } = meta.usage;
284
- if (Number.isFinite(input) || Number.isFinite(output)) {
285
- bits.push(`${t.gray}${fmtTokens(input || 0)}/${fmtTokens(output || 0)}${RESET}`);
286
- }
282
+ // The producer (chatflow.js's accounting row) pushes the split flat, as
283
+ // `input`/`output` reading it as `meta.usage.input` here meant the pair was
284
+ // never printed on this path even though the docstring above promises it.
285
+ // Both shapes are accepted: `usage` is what the desktop's renderer hands over.
286
+ const split = meta.usage && typeof meta.usage === 'object' ? meta.usage : meta;
287
+ const { input, output } = split;
288
+ if (Number.isFinite(input) || Number.isFinite(output)) {
289
+ bits.push(`${t.gray}${fmtTokens(input || 0)}/${fmtTokens(output || 0)}${RESET}`);
287
290
  }
288
291
  if (meta.eur != null) bits.push(`${meta.eur > 0 ? t.coral : t.green}${fmtEur(meta.eur)}${RESET}`);
289
292
  if (meta.ms != null) bits.push(`${t.gray}${fmtElapsed(meta.ms)}${RESET}`);
package/src/screens.js CHANGED
@@ -35,7 +35,8 @@ const { C, BOLD, BOLD_OFF, GLYPH, THEME_TABLE, themeOf } = require('./theme.js')
35
35
  const { welcomeArtParts } = require('./art.js');
36
36
  const { renderDiffPreview } = require('./markdown.js');
37
37
  const render = require('./render.js');
38
- const { updateConfig, configExists } = require('./config.js');
38
+ const { updateConfig, configExists, loadConfig } = require('./config.js');
39
+ const { updateNotice, updateLine } = require('./update.js');
39
40
  const credentials = require('./credentials.js');
40
41
 
41
42
  const VERSION = require('../package.json').version;
@@ -251,6 +252,22 @@ function welcomeLines(ctx, cols, rows, firstRun = true) {
251
252
  lines.push([span(t.gold, '━' + '─'.repeat(Math.max(0, cols - 2)) + '━')]);
252
253
  lines.push([span('', '')]);
253
254
 
255
+ // A newer release, if the last run found one. Reads cache only — the
256
+ // refresh it may kick off lands for the NEXT launch, so drawing this box
257
+ // never waits on the registry.
258
+ let updateMsg = null;
259
+ try {
260
+ const cfg = loadConfig();
261
+ const notice = updateNotice({
262
+ current: VERSION,
263
+ cache: cfg.updateCheck,
264
+ save: (v) => { try { updateConfig({ updateCheck: v }); } catch { /* not fatal */ } },
265
+ });
266
+ updateMsg = updateLine({ current: VERSION, ...notice });
267
+ } catch {
268
+ // An update notice is never worth failing a launch over.
269
+ }
270
+
254
271
  const parts = welcomeArtParts(cols);
255
272
  if (cols >= parts.width + 2) {
256
273
  const leftPad = Math.max(0, Math.floor((cols - parts.width) / 2));
@@ -260,6 +277,10 @@ function welcomeLines(ctx, cols, rows, firstRun = true) {
260
277
  } else {
261
278
  lines.push(centered(`${PRODUCT}`, cols, t.gold + BOLD));
262
279
  }
280
+ if (updateMsg) {
281
+ lines.push([span('', '')]);
282
+ lines.push(centered(updateMsg, cols, t.gold));
283
+ }
263
284
  lines.push([span('', '')]);
264
285
 
265
286
  const title = firstRun ? `Welcome to ${PRODUCT}` : 'Welcome back!';
package/src/update.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * "There is a newer version" — the notice this CLI never had.
3
+ *
4
+ * Checked on 2026-09-15: there was no update check anywhere in the client, so
5
+ * a user learned about a release only by guessing to run
6
+ * `npm i -g aegiscode@latest`. Publishing therefore reached almost nobody, and
7
+ * the installed base drifted months behind the registry.
8
+ *
9
+ * Three rules, because an update check is a background nicety that must never
10
+ * become a liability:
11
+ * · it never blocks — the caller gets a cached answer immediately and the
12
+ * network call settles whenever it settles;
13
+ * · it never throws — offline, proxied, rate-limited or garbage JSON all
14
+ * mean "no notice", not a broken CLI;
15
+ * · it never nags — one check a day, cached, and nothing printed when the
16
+ * user is already current.
17
+ */
18
+
19
+ const https = require('node:https');
20
+
21
+ const REGISTRY = 'https://registry.npmjs.org';
22
+ const PKG = 'aegiscode';
23
+ /** One check a day. A CLI that pings the registry every launch is spyware. */
24
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
25
+ /** The registry is not on the critical path; give up quickly and silently. */
26
+ const TIMEOUT_MS = 2500;
27
+
28
+ /** Compare two semver-ish strings. Returns true when `latest` is newer. */
29
+ function isNewer(latest, current) {
30
+ const parse = (v) =>
31
+ String(v || '')
32
+ .trim()
33
+ .replace(/^v/, '')
34
+ .split('-')[0] // a prerelease never counts as newer than its release
35
+ .split('.')
36
+ .map((n) => Number.parseInt(n, 10) || 0);
37
+ const a = parse(latest);
38
+ const b = parse(current);
39
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
40
+ const x = a[i] || 0;
41
+ const y = b[i] || 0;
42
+ if (x !== y) return x > y;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /** Fetch the registry's `latest` dist-tag. Resolves null on any failure. */
48
+ function fetchLatest({ pkg = PKG, timeoutMs = TIMEOUT_MS } = {}) {
49
+ return new Promise((resolve) => {
50
+ let settled = false;
51
+ const done = (v) => {
52
+ if (!settled) {
53
+ settled = true;
54
+ resolve(v);
55
+ }
56
+ };
57
+ try {
58
+ // The abbreviated metadata document: a few KB instead of the full
59
+ // packument, which for a package with this many releases is megabytes.
60
+ const req = https.get(
61
+ `${REGISTRY}/${pkg}`,
62
+ { headers: { accept: 'application/vnd.npm.install-v1+json' }, timeout: timeoutMs },
63
+ (res) => {
64
+ if (res.statusCode !== 200) {
65
+ res.resume();
66
+ return done(null);
67
+ }
68
+ let body = '';
69
+ res.setEncoding('utf8');
70
+ res.on('data', (c) => {
71
+ body += c;
72
+ if (body.length > 2_000_000) {
73
+ req.destroy();
74
+ done(null);
75
+ }
76
+ });
77
+ res.on('end', () => {
78
+ try {
79
+ const tags = JSON.parse(body)['dist-tags'];
80
+ done((tags && tags.latest) || null);
81
+ } catch {
82
+ done(null);
83
+ }
84
+ });
85
+ }
86
+ );
87
+ req.on('timeout', () => {
88
+ req.destroy();
89
+ done(null);
90
+ });
91
+ req.on('error', () => done(null));
92
+ } catch {
93
+ done(null);
94
+ }
95
+ });
96
+ }
97
+
98
+ /**
99
+ * The notice to show, from cache. Never waits on the network.
100
+ *
101
+ * `cache` is the persisted `{ checkedAt, latest }` blob; `save` persists a new
102
+ * one. Returns `{ latest, behind }`, and kicks off a refresh in the background
103
+ * when the cache is stale.
104
+ */
105
+ function updateNotice({
106
+ current,
107
+ cache,
108
+ save,
109
+ now = Date.now(),
110
+ fetchImpl = fetchLatest,
111
+ intervalMs = CHECK_INTERVAL_MS,
112
+ } = {}) {
113
+ const c = cache || {};
114
+ const fresh = typeof c.checkedAt === 'number' && now - c.checkedAt < intervalMs;
115
+
116
+ if (!fresh) {
117
+ // Fire and forget: this run reports on what was already known, and the
118
+ // answer lands for the next one. A first run therefore never shows a
119
+ // notice, which is correct — it has nothing to compare against yet.
120
+ Promise.resolve(fetchImpl())
121
+ .then((latest) => {
122
+ if (latest && typeof save === 'function') save({ checkedAt: now, latest });
123
+ })
124
+ .catch(() => {});
125
+ }
126
+
127
+ const latest = c.latest || null;
128
+ return { latest, behind: !!(latest && isNewer(latest, current)) };
129
+ }
130
+
131
+ /** One line for the welcome box, or null when there is nothing to say. */
132
+ function updateLine({ current, latest, behind }) {
133
+ if (!behind) return null;
134
+ return `Update available: ${current} → ${latest} run: npm i -g ${PKG}@latest`;
135
+ }
136
+
137
+ module.exports = { isNewer, fetchLatest, updateNotice, updateLine, PKG, CHECK_INTERVAL_MS };
@@ -465,6 +465,9 @@ function createClient(opts = {}) {
465
465
  const decoder = new TextDecoder();
466
466
  let buffer = '';
467
467
  let fullText = '';
468
+ // Mirrors fullText for the reasoning channel so a reasoning SNAPSHOT is
469
+ // deduplicated the same way a content snapshot is.
470
+ let reasoningText = '';
468
471
  let resultModel = body.model;
469
472
  let usage = null;
470
473
  let sseError = '';
@@ -489,13 +492,26 @@ function createClient(opts = {}) {
489
492
  // default Nexus turn (brain model id, checkbox off) dying at 60s — with
490
493
  // the server already past its own fan-out deadline and every worker
491
494
  // billed. See idleBudgetFor().
495
+ // The budget is measured from the last real `data:` frame, not from the
496
+ // last read. SSE keep-alive comments (": keep-alive") are transport
497
+ // framing, and a stalled upstream can emit them forever: a watchdog armed
498
+ // per read() is reset by every one of them and so never fires, which is
499
+ // exactly the hang this guard exists to prevent. Only a parsed payload
500
+ // moves `lastPayloadAt` below.
492
501
  const idleMs = idleBudgetFor(res, idleTimeoutMs);
502
+ let lastPayloadAt = Date.now();
503
+ let keepAlives = 0;
493
504
  async function readWithIdleTimeout() {
494
505
  let timer;
506
+ const remaining = Math.max(0, idleMs - (Date.now() - lastPayloadAt));
495
507
  const timeout = new Promise((_, reject) => {
496
508
  timer = setTimeout(() => {
497
- reject(new Error(`stream stalled - no data for ${idleMs / 1000}s`));
498
- }, idleMs);
509
+ reject(new Error(
510
+ keepAlives > 0
511
+ ? `stream stalled - only keep-alives for ${idleMs / 1000}s`
512
+ : `stream stalled - no data for ${idleMs / 1000}s`
513
+ ));
514
+ }, remaining);
499
515
  });
500
516
  try {
501
517
  return await Promise.race([reader.read(), timeout]);
@@ -520,7 +536,11 @@ function createClient(opts = {}) {
520
536
 
521
537
  for (const rawLine of lines) {
522
538
  const line = rawLine.trim();
523
- if (!line.startsWith('data:')) continue;
539
+ if (!line.startsWith('data:')) {
540
+ if (line.startsWith(':')) keepAlives++;
541
+ continue;
542
+ }
543
+ lastPayloadAt = Date.now(); // a real frame: the stream is still speaking
524
544
  const payload = line.slice(5).trim();
525
545
  if (!payload || payload === '[DONE]') continue;
526
546
  let json;
@@ -548,24 +568,48 @@ function createClient(opts = {}) {
548
568
  // synthesis pass writes the visible answer. Deliberately kept out of
549
569
  // `fullText` — deliberation is not an answer, and counting it would
550
570
  // make a no-answer turn look answered to every caller's empty-check.
551
- const reasoning =
552
- (choice &&
553
- ((choice.delta && choice.delta.reasoning_content) ||
554
- (choice.message && choice.message.reasoning_content))) ||
555
- '';
556
- if (reasoning) {
557
- if (typeof onReasoning === 'function') onReasoning(reasoning);
558
- else onStream({ reasoning });
559
- }
560
- const delta =
561
- (choice &&
562
- ((choice.delta && choice.delta.content) ||
563
- (choice.message && choice.message.content))) ||
564
- '';
565
- if (delta) {
566
- fullText += delta;
567
- onStream({ delta });
568
- }
571
+ // `delta.*` is an INCREMENT; `message.*` is a SNAPSHOT of the whole
572
+ // message so far. Collapsing them with `||` made a stream that ends
573
+ // with a message snapshot append the entire answer a second time —
574
+ // the duplicated text in the CLI. They are merged here through one
575
+ // helper that emits only what the caller has not already seen.
576
+ const advance = (increment, snapshot, seen, emit) => {
577
+ if (typeof increment === 'string' && increment) {
578
+ emit(increment);
579
+ return seen + increment;
580
+ }
581
+ if (typeof snapshot === 'string' && snapshot) {
582
+ if (!seen) { emit(snapshot); return snapshot; }
583
+ // The usual shape: the snapshot restates everything streamed so
584
+ // far, so only the tail is new.
585
+ if (snapshot.startsWith(seen)) {
586
+ const tail = snapshot.slice(seen.length);
587
+ if (tail) emit(tail);
588
+ return snapshot;
589
+ }
590
+ // Disjoint from what was already shown — cannot be reconciled, and
591
+ // appending it would duplicate. Keep what the caller has seen.
592
+ return seen;
593
+ }
594
+ return seen;
595
+ };
596
+
597
+ reasoningText = advance(
598
+ choice && choice.delta && choice.delta.reasoning_content,
599
+ choice && choice.message && choice.message.reasoning_content,
600
+ reasoningText,
601
+ (chunk) => {
602
+ if (typeof onReasoning === 'function') onReasoning(chunk);
603
+ else onStream({ reasoning: chunk });
604
+ }
605
+ );
606
+
607
+ fullText = advance(
608
+ choice && choice.delta && choice.delta.content,
609
+ choice && choice.message && choice.message.content,
610
+ fullText,
611
+ (chunk) => onStream({ delta: chunk })
612
+ );
569
613
  const fragments =
570
614
  (choice && choice.delta && choice.delta.tool_calls) ||
571
615
  (choice && choice.message && choice.message.tool_calls);
@@ -104,6 +104,98 @@ function atomicWrite(file, data) {
104
104
  } catch {}
105
105
  }
106
106
 
107
+ /** How long a lock may be held before another writer treats it as abandoned. */
108
+ const LOCK_STALE_MS = 2000;
109
+ /** Total time a writer will wait for the lock before proceeding unlocked. */
110
+ const LOCK_WAIT_MS = 500;
111
+
112
+ function lockFile(dir) {
113
+ return `${storeFile(dir)}.lock`;
114
+ }
115
+
116
+ /**
117
+ * Best-effort advisory lock around the read-modify-write in `mutate()`.
118
+ *
119
+ * A private store owned by one process did not need this. A *shared* one does:
120
+ * the desktop app and a terminal session are two long-lived processes that both
121
+ * rewrite this file wholesale, and interleaved read-modify-write would drop
122
+ * whichever turn lost the race. The lock is deliberately soft — a crash must
123
+ * not wedge every future write, so a lock older than LOCK_STALE_MS is stolen,
124
+ * and a writer that cannot get it in LOCK_WAIT_MS proceeds anyway (losing at
125
+ * worst the same race that exists today, rather than refusing to save).
126
+ *
127
+ * @returns {boolean} whether the lock was acquired
128
+ */
129
+ function acquireLock(dir) {
130
+ const file = lockFile(dir);
131
+ // The lock is taken *before* the write that used to create the directory, so
132
+ // this is now the first thing to touch it — a first run (or a fresh
133
+ // AEGISCODE_HOME) would otherwise spin here forever waiting on a lock it can
134
+ // never create.
135
+ try {
136
+ fs.mkdirSync(path.dirname(file), { recursive: true });
137
+ } catch {
138
+ return false;
139
+ }
140
+ const deadline = Date.now() + LOCK_WAIT_MS;
141
+ // Bounded, so no combination of "lock vanished" / "lock unreadable" can spin
142
+ // this loop: a writer that cannot decide always proceeds unlocked instead.
143
+ const MAX_ATTEMPTS = 200;
144
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
145
+ try {
146
+ const fd = fs.openSync(file, 'wx');
147
+ fs.writeSync(fd, String(process.pid));
148
+ fs.closeSync(fd);
149
+ return true;
150
+ } catch {
151
+ let age = null;
152
+ try {
153
+ age = Date.now() - fs.statSync(file).mtimeMs;
154
+ } catch {
155
+ // Released between the open and the stat — try immediately.
156
+ continue;
157
+ }
158
+ if (age > LOCK_STALE_MS) {
159
+ try {
160
+ fs.unlinkSync(file);
161
+ } catch {}
162
+ continue;
163
+ }
164
+ if (Date.now() >= deadline) return false;
165
+ // Busy-wait: the critical section is a few hundred microseconds of
166
+ // stringify + rename, so sleeping the event loop would cost more than it
167
+ // saves, and this path is not on the streaming critical path.
168
+ const until = Date.now() + 5;
169
+ while (Date.now() < until) {
170
+ /* spin a few ms */
171
+ }
172
+ }
173
+ }
174
+ return false;
175
+ }
176
+
177
+ function releaseLock(dir) {
178
+ try {
179
+ fs.unlinkSync(lockFile(dir));
180
+ } catch {}
181
+ }
182
+
183
+ /**
184
+ * Read-modify-write the whole store under the lock. Every mutation goes through
185
+ * here so the locking is one behaviour, not a thing each caller must remember.
186
+ */
187
+ function mutate(dir, mutator) {
188
+ const locked = acquireLock(dir);
189
+ try {
190
+ const sessions = load(dir);
191
+ const result = mutator(sessions);
192
+ save(dir, sessions);
193
+ return result;
194
+ } finally {
195
+ if (locked) releaseLock(dir);
196
+ }
197
+ }
198
+
107
199
  function save(dir, sessions) {
108
200
  atomicWrite(storeFile(dir), sessions);
109
201
  }
@@ -124,29 +216,29 @@ function trimMessages(messages) {
124
216
  function upsertSession(dir, session) {
125
217
  const id = session && session.id;
126
218
  if (!id) throw new Error('session.id is required');
127
- const sessions = load(dir);
128
- const prev = sessions[id] || { messages: [] };
129
- sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
130
- if (session.pending !== false) sessions[id].pending = true;
131
- if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
132
- sessions[id].seq = nextSeq(sessions);
133
- save(dir, sessions);
134
- return sessions[id];
219
+ return mutate(dir, (sessions) => {
220
+ const prev = sessions[id] || { messages: [] };
221
+ sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
222
+ if (session.pending !== false) sessions[id].pending = true;
223
+ if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
224
+ sessions[id].seq = nextSeq(sessions);
225
+ return sessions[id];
226
+ });
135
227
  }
136
228
 
137
229
  /** Append one message to a session (crash-safe). */
138
230
  function appendMessage(dir, sessionId, message) {
139
231
  if (!sessionId) throw new Error('sessionId is required');
140
- const sessions = load(dir);
141
- const session = sessions[sessionId] || { id: sessionId, messages: [] };
142
- const messages = Array.isArray(session.messages) ? session.messages : [];
143
- session.messages = trimMessages(messages.concat(message));
144
- session.updatedAt = Date.now();
145
- session.pending = true;
146
- session.seq = nextSeq(sessions);
147
- sessions[sessionId] = session;
148
- save(dir, sessions);
149
- return session;
232
+ return mutate(dir, (sessions) => {
233
+ const session = sessions[sessionId] || { id: sessionId, messages: [] };
234
+ const messages = Array.isArray(session.messages) ? session.messages : [];
235
+ session.messages = trimMessages(messages.concat(message));
236
+ session.updatedAt = Date.now();
237
+ session.pending = true;
238
+ session.seq = nextSeq(sessions);
239
+ sessions[sessionId] = session;
240
+ return session;
241
+ });
150
242
  }
151
243
 
152
244
  /**
@@ -166,34 +258,34 @@ function appendMessage(dir, sessionId, message) {
166
258
  function recordExchange(dir, exchange) {
167
259
  const e = exchange || {};
168
260
  if (!e.sessionId) return null;
169
- const sessions = load(dir);
170
261
  const id = e.sessionId;
171
- const session = sessions[id] || { id, messages: [] };
172
- const messages = Array.isArray(session.messages) ? session.messages : [];
173
- const ts = e.ts || new Date().toISOString();
174
- const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
175
- const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
176
- if (e.tokens) assistantMessage.tokens = e.tokens;
177
- if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
178
- if (e.status) assistantMessage.status = e.status;
179
- if (e.origin) {
180
- userMessage.origin = e.origin;
181
- assistantMessage.origin = e.origin;
182
- }
183
- session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
184
- session.title = session.title || String(e.prompt || '').slice(0, 60);
185
- if (e.cwd) session.cwd = e.cwd;
186
- session.origin = e.origin || session.origin || 'unknown';
187
- session.updatedAt = Date.now();
188
- // Explicit, not merely "leave it unset": `listPending` treats an absent flag
189
- // as pending (sessions predating the field default to queued), so an
190
- // undefined here would quietly enrol every terminal session in the desktop's
191
- // push queue the exact thing the `pending: false` default is for.
192
- session.pending = e.pending === true ? true : false;
193
- session.seq = nextSeq(sessions);
194
- sessions[id] = session;
195
- save(dir, sessions);
196
- return session;
262
+ return mutate(dir, (sessions) => {
263
+ const session = sessions[id] || { id, messages: [] };
264
+ const messages = Array.isArray(session.messages) ? session.messages : [];
265
+ const ts = e.ts || new Date().toISOString();
266
+ const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
267
+ const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
268
+ if (e.tokens) assistantMessage.tokens = e.tokens;
269
+ if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
270
+ if (e.status) assistantMessage.status = e.status;
271
+ if (e.origin) {
272
+ userMessage.origin = e.origin;
273
+ assistantMessage.origin = e.origin;
274
+ }
275
+ session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
276
+ session.title = session.title || String(e.prompt || '').slice(0, 60);
277
+ if (e.cwd) session.cwd = e.cwd;
278
+ session.origin = e.origin || session.origin || 'unknown';
279
+ session.updatedAt = Date.now();
280
+ // Explicit, not merely "leave it unset": `listPending` treats an absent flag
281
+ // as pending (sessions predating the field default to queued), so an
282
+ // undefined here would quietly enrol every terminal session in the desktop's
283
+ // push queue the exact thing the `pending: false` default is for.
284
+ session.pending = e.pending === true ? true : false;
285
+ session.seq = nextSeq(sessions);
286
+ sessions[id] = session;
287
+ return session;
288
+ });
197
289
  }
198
290
 
199
291
  function listSessions(dir) {
@@ -212,37 +304,35 @@ function getSession(dir, id) {
212
304
  }
213
305
 
214
306
  function deleteSession(dir, id) {
215
- const sessions = load(dir);
216
- delete sessions[id];
217
- save(dir, sessions);
218
- return { ok: true };
307
+ return mutate(dir, (sessions) => {
308
+ delete sessions[id];
309
+ return { ok: true };
310
+ });
219
311
  }
220
312
 
221
313
  /** Clear the pending flag after a successful cloud push. `remote.remoteId`,
222
314
  * when the server assigns its own conversation id, is stashed alongside. */
223
315
  function markSynced(dir, id, remote) {
224
- const sessions = load(dir);
225
- const session = sessions[id];
226
- if (!session) return null;
227
- session.pending = false;
228
- session.lastSyncedAt = Date.now();
229
- if (remote && remote.remoteId) session.remoteId = remote.remoteId;
230
- session.seq = nextSeq(sessions);
231
- sessions[id] = session;
232
- save(dir, sessions);
233
- return session;
316
+ return mutate(dir, (sessions) => {
317
+ const session = sessions[id];
318
+ if (!session) return null;
319
+ session.pending = false;
320
+ session.lastSyncedAt = Date.now();
321
+ if (remote && remote.remoteId) session.remoteId = remote.remoteId;
322
+ session.seq = nextSeq(sessions);
323
+ return session;
324
+ });
234
325
  }
235
326
 
236
327
  /** Force a session back into the retry queue (e.g. a push that partially failed). */
237
328
  function markPending(dir, id) {
238
- const sessions = load(dir);
239
- const session = sessions[id];
240
- if (!session) return null;
241
- session.pending = true;
242
- session.seq = nextSeq(sessions);
243
- sessions[id] = session;
244
- save(dir, sessions);
245
- return session;
329
+ return mutate(dir, (sessions) => {
330
+ const session = sessions[id];
331
+ if (!session) return null;
332
+ session.pending = true;
333
+ session.seq = nextSeq(sessions);
334
+ return session;
335
+ });
246
336
  }
247
337
 
248
338
  /** Sessions with local content the cloud hasn't confirmed yet (including
@@ -259,32 +349,33 @@ function listPending(dir) {
259
349
  */
260
350
  function mergeRemoteSessions(dir, remoteSessions) {
261
351
  const list = Array.isArray(remoteSessions) ? remoteSessions : [];
262
- const sessions = load(dir);
263
- let merged = 0;
264
- for (const remote of list) {
265
- const id = remote && (remote.session_id || remote.id);
266
- if (!id) continue;
267
- const local = sessions[id];
268
- const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
269
- if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
270
- continue;
352
+ if (!list.length) return 0;
353
+ return mutate(dir, (sessions) => {
354
+ let merged = 0;
355
+ for (const remote of list) {
356
+ const id = remote && (remote.session_id || remote.id);
357
+ if (!id) continue;
358
+ const local = sessions[id];
359
+ const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
360
+ if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
361
+ continue;
362
+ }
363
+ sessions[id] = {
364
+ id,
365
+ title: remote.title || (local && local.title) || '',
366
+ messages: Array.isArray(remote.messages) ? remote.messages : [],
367
+ updatedAt: remoteUpdatedAt || Date.now(),
368
+ pending: false,
369
+ lastSyncedAt: Date.now(),
370
+ remoteId: remote.session_id || remote.id,
371
+ origin: remote.source || (local && local.origin) || 'cloud',
372
+ version: STORE_VERSION,
373
+ };
374
+ sessions[id].seq = nextSeq(sessions);
375
+ merged += 1;
271
376
  }
272
- sessions[id] = {
273
- id,
274
- title: remote.title || (local && local.title) || '',
275
- messages: Array.isArray(remote.messages) ? remote.messages : [],
276
- updatedAt: remoteUpdatedAt || Date.now(),
277
- pending: false,
278
- lastSyncedAt: Date.now(),
279
- remoteId: remote.session_id || remote.id,
280
- origin: remote.source || (local && local.origin) || 'cloud',
281
- version: STORE_VERSION,
282
- };
283
- sessions[id].seq = nextSeq(sessions);
284
- merged += 1;
285
- }
286
- if (merged) save(dir, sessions);
287
- return merged;
377
+ return merged;
378
+ });
288
379
  }
289
380
 
290
381
  /**
@@ -355,14 +446,16 @@ function adopt(dir, fromDir) {
355
446
  if (listSessions(dir).length) {
356
447
  return { adopted: false, sessions: 0, from, reason: 'store already has sessions' };
357
448
  }
358
- const next = { ...legacy, __seq: legacy.__seq || 0 };
359
- for (const s of incoming) {
360
- // Adopted sessions keep their own history; they are already local content.
361
- s.seq = nextSeq(next);
362
- s.adoptedFrom = from;
363
- }
364
- next.version = STORE_VERSION;
365
- save(dir, next);
449
+ mutate(dir, (sessions) => {
450
+ for (const s of incoming) {
451
+ // Adopted sessions keep their own history; they are already local content.
452
+ sessions[s.id] = { ...s, adoptedFrom: from, version: STORE_VERSION };
453
+ sessions[s.id].seq = nextSeq(sessions);
454
+ }
455
+ sessions.__seq = Math.max(sessions.__seq || 0, legacy.__seq || 0);
456
+ sessions.version = STORE_VERSION;
457
+ return null;
458
+ });
366
459
  return { adopted: true, sessions: incoming.length, from };
367
460
  }
368
461
 
@@ -397,6 +490,9 @@ module.exports = {
397
490
  MAX_MESSAGES_PER_SESSION,
398
491
  storeDir,
399
492
  storeFile,
493
+ lockFile,
494
+ LOCK_STALE_MS,
495
+ LOCK_WAIT_MS,
400
496
  toEpochMs,
401
497
  load,
402
498
  save,
@@ -109,18 +109,38 @@ const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
109
109
  const AUTONOMOUS_IDLE_TIMEOUT_MS = 15 * 60_000;
110
110
 
111
111
  /**
112
- * Only ever raises a too-low budget for a DeepSeek reasoning model never
113
- * lowers whatever the caller (renderer dropdown, or "adaptive" ceiling)
114
- * already asked for. Everything else (non-DeepSeek models, non-reasoning
115
- * DeepSeek ids like deepseek-chat) passes through untouched. Effort defaults
116
- * to 'high' since custom endpoints have no effort selector of their own
117
- * (that UI is aegis-class/autonomous-only) matching aegiscodex-dev's own
118
- * default effort.
112
+ * The budget a DeepSeek reasoning model runs on, resolved from EXACTLY ONE
113
+ * authority per call.
114
+ *
115
+ * A caller-stated number IS the budget, and is returned verbatim. For the
116
+ * non-pooled classes the renderer's max-tokens dropdown is the only budget
117
+ * control on offerupdateBudgetControls hides the effort row for them — so
118
+ * silently raising that number to an effort rung is precisely what made the
119
+ * figure beside the dropdown untrustworthy. The old form was
120
+ * `Math.max(stated, EFFORT_TOKEN_BUDGET[eff])`, which could only ever raise a
121
+ * deliberate cap: a caller asking for 1024 ran on 32768, and the number the
122
+ * UI displayed was never the number the call used.
123
+ *
124
+ * The effort rung is the DEFAULT, consulted only when no number was stated at
125
+ * all (the pooled class, which the renderer sends `effort` for and which the
126
+ * server sizes itself). This is the same rule doubledBudget() follows for its
127
+ * truncation retry: a stated cap is never overridden, by a rung or an order of
128
+ * magnitude.
129
+ *
130
+ * Truncated and empty turns are handled where they belong — the doubled-budget
131
+ * retry plus emptyTurnError — rather than by inflating the caller's ceiling up
132
+ * front. Escalating on a demonstrated empty turn is strictly cheaper than
133
+ * pre-emptively granting the top rung to every reasoning call.
134
+ *
135
+ * Everything else (non-DeepSeek models, non-reasoning DeepSeek ids like
136
+ * deepseek-chat) passes through untouched.
119
137
  */
120
- function deepseekReasoningFloor(model, maxTokens, effort) {
138
+ function reasoningBudget(model, maxTokens, effort) {
121
139
  if (!DEEPSEEK_REASONING_MODEL_RE.test(String(model || ''))) return maxTokens;
140
+ const stated = Number(maxTokens);
141
+ if (Number.isFinite(stated) && stated > 0) return stated;
122
142
  const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
123
- return Math.max(Number(maxTokens) || 0, EFFORT_TOKEN_BUDGET[eff]);
143
+ return EFFORT_TOKEN_BUDGET[eff];
124
144
  }
125
145
 
126
146
  /** Relay model entries arrive as ids or objects; keep only real model ids. */
@@ -545,7 +565,24 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
545
565
  messages: opts.messages,
546
566
  model: opts.model,
547
567
  mode: opts.mode,
548
- maxTokens: opts.maxTokens,
568
+ // Only a cap the caller STATED travels; an effort-derived one does not.
569
+ // The server derives its own budget from `effort` (aegis1 pass_budgets
570
+ // splits its ladder across workers + synthesis), so forwarding the
571
+ // derived number states one decision twice — and the copies had already
572
+ // drifted: 974adc5 doubled aegis1's ladder while this side stood still,
573
+ // leaving the client capping below the budget it displayed. The cap was
574
+ // never the one the renderer showed either (updateBudgetControls hides
575
+ // the max-tokens dropdown for the pooled class). Omitting the field is
576
+ // what tells aegis1 "no cap stated — let effort decide", the same
577
+ // contract aegiscodex-dev sends.
578
+ //
579
+ // A cap the caller DID state is a different thing, and dropping it was
580
+ // a bug: aegis1 reads a body max_tokens as a ceiling over its ladder,
581
+ // so omitting it does not bound the call — it grants the full top rung
582
+ // instead. A deliberate 4096 would have run at 32768, which is the same
583
+ // "only ever raise the caller's ceiling" failure the old
584
+ // Math.max(Number(maxTokens) || 0, EFFORT_TOKEN_BUDGET[eff]) had.
585
+ maxTokens: opts.statedMaxTokens,
549
586
  stream: opts.stream !== false,
550
587
  // The pooled (Nexus) brain is streamed, and an OpenAI-compatible SSE
551
588
  // stream reports no token usage unless asked. Without this the Aegis
@@ -638,7 +675,16 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
638
675
  async function chat(payload, onDelta) {
639
676
  const cls = payload && payload.class;
640
677
  const model = payload && payload.model;
641
- const maxTokens = deepseekReasoningFloor(model, payload && payload.maxTokens, payload && payload.effort);
678
+ const maxTokens = reasoningBudget(model, payload && payload.maxTokens, payload && payload.effort);
679
+ // The caller's OWN number, kept apart from `maxTokens` above. That one
680
+ // collapses two different facts into a single value — "the caller stated
681
+ // 4096" and "effort implies 32768" — and the pooled path must treat them
682
+ // differently. A stated cap is a liability ceiling the server honours
683
+ // downward (aegis1 pass_budgets: total = min(ladder, max_tokens x passes));
684
+ // an effort-derived one is the server's own arithmetic stated twice, and
685
+ // sending it is how the two copies came to disagree. So the pooled call
686
+ // forwards only what the caller actually asked for.
687
+ const statedMaxTokens = Number(payload && payload.maxTokens) > 0 ? Number(payload.maxTokens) : undefined;
642
688
  // "Work autonomously" — routes this call through aegis1's pool_brain
643
689
  // worker fan-out (services/pool_brain.py: N reasoning workers + a
644
690
  // synthesis pass) instead of a single provider call. UI-gated to the
@@ -710,7 +756,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
710
756
  }
711
757
 
712
758
  const base = {
713
- cls, model, mode: payload && payload.mode, maxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
759
+ cls, model, mode: payload && payload.mode, maxTokens, statedMaxTokens, autonomous, sessionId, signal, onDelta, cfg, apiKey, toolChoice,
714
760
  effort: payload && payload.effort,
715
761
  workers: payload && payload.workers,
716
762
  onReasoning,
@@ -741,6 +787,26 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
741
787
  let truncationRetried = false;
742
788
  let synthesisDone = false;
743
789
 
790
+ // Round cap — the bound aegiscodex-dev has had all along and this engine
791
+ // did not. Removing the old fixed cap (12) was right in spirit and wrong
792
+ // in effect: it left the turn with NO horizon, and on 2026-09-15 the
793
+ // question "can you check the plan" ran ~70 rounds, grew the context
794
+ // from 2,260 to 116,011 tokens, cost about EUR 2, and spent those rounds
795
+ // writing 400 lines of unrequested code into the source tree. Each round
796
+ // re-sends the whole conversation, so an unbounded loop gets more
797
+ // expensive the longer it runs.
798
+ //
799
+ // The numbers match aegiscodex-dev's (src/autonomous.js) so both clients
800
+ // behave the same: 24 rounds for a chat turn, 40 for an autonomous one.
801
+ // Env-overridable for a deliberately long job.
802
+ const maxRounds = (() => {
803
+ const name = autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS';
804
+ const raw = Number.parseInt(process.env[name] || '', 10);
805
+ if (Number.isFinite(raw) && raw > 0) return raw;
806
+ return autonomous ? 40 : 24;
807
+ })();
808
+ let round = 0;
809
+
744
810
  // Token accounting for the whole TURN, not just its last round. An
745
811
  // agentic turn makes one provider call per tool round, and returning only
746
812
  // the final round's `usage` (what this did) reported a fraction of what
@@ -789,6 +855,24 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
789
855
  };
790
856
 
791
857
  for (;;) {
858
+ // Stop and SAY so. A turn that reaches its horizon has usually done
859
+ // real work; ending silently would paint an empty answer over it,
860
+ // which is the same "(empty response)" failure the guards below exist
861
+ // to prevent.
862
+ if (round >= maxRounds) {
863
+ const note =
864
+ `[stopped at ${maxRounds} tool rounds` +
865
+ `${turnUsage.total_tokens ? `, ${turnUsage.total_tokens.toLocaleString()} tokens` : ''}` +
866
+ `. Ask again to continue, or raise ` +
867
+ `${autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS'}.]`;
868
+ if (rootOnDelta) rootOnDelta({ delta: `\n\n${note}` });
869
+ return withTurnUsage({
870
+ model: base.model,
871
+ choices: [{ message: { content: note }, finish_reason: 'length' }],
872
+ stoppedOnRounds: true,
873
+ });
874
+ }
875
+ round += 1;
792
876
  const opts = { ...base, system, messages: history, prompt, tools: toolSchemas };
793
877
  let res;
794
878
  try {
@@ -834,6 +918,12 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
834
918
  ...opts,
835
919
  singlePass: true,
836
920
  maxTokens: doubledBudget(opts.maxTokens),
921
+ // Doubling applies to the pooled path only when the caller stated a
922
+ // number. With none stated, the server's effort ladder IS the
923
+ // budget, and sending doubledBudget's 8192 floor would *lower* it
924
+ // (aegis1 reads max_tokens as a ceiling over the ladder) — a
925
+ // "double the budget" retry that halves it at high effort.
926
+ statedMaxTokens: opts.statedMaxTokens ? doubledBudget(opts.statedMaxTokens) : undefined,
837
927
  });
838
928
  addUsage(res);
839
929
  }