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 +1 -1
- package/src/app.js +11 -2
- package/src/chatflow.js +125 -36
- package/src/config.js +4 -0
- package/src/models.js +6 -2
- package/src/permissions.js +51 -8
- package/src/render.js +8 -5
- package/src/screens.js +22 -1
- package/src/update.js +137 -0
- package/vendor/client/aegis.js +65 -21
- package/vendor/client/session-store.js +196 -100
- package/vendor/desktop/lib/local/engine.js +102 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.5.
|
|
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
|
|
73
|
-
: name
|
|
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())
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
const bits =
|
|
298
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
786
|
-
|
|
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
|
-
//
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
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 ·
|
|
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. */
|
package/src/permissions.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 };
|
package/vendor/client/aegis.js
CHANGED
|
@@ -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(
|
|
498
|
-
|
|
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:'))
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
if (typeof
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
(
|
|
562
|
-
(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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 offer — updateBudgetControls 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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|