aegiscode 6.5.5 → 6.5.6
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 +2 -2
- package/src/app.js +24 -2
- package/src/chatflow.js +44 -20
- package/vendor/desktop/lib/local/engine.js +74 -11
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegiscode",
|
|
3
3
|
"productName": "AEGIS Code",
|
|
4
|
-
"version": "6.5.
|
|
5
|
-
"description": "aegiscode
|
|
4
|
+
"version": "6.5.6",
|
|
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",
|
|
8
8
|
"email": "nborneklint@gmail.com"
|
package/src/app.js
CHANGED
|
@@ -65,6 +65,26 @@ function defaultSystemPrompt() {
|
|
|
65
65
|
return _defaultSystem || undefined;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// The engine accepts exactly 'once' | 'session' and coerces anything else to
|
|
69
|
+
// 'deny' (vendor/desktop/lib/local/engine.js → respondApproval). Bridging the
|
|
70
|
+
// vocabularies here, rather than trusting every prompter to speak the engine's
|
|
71
|
+
// dialect, is what stops an approval from silently becoming a denial. The
|
|
72
|
+
// failure mode is invisible from the outside: the tool is refused, the model
|
|
73
|
+
// is told the user declined, and it retries against a gate that can only ever
|
|
74
|
+
// say no — the turn burns its whole budget.
|
|
75
|
+
//
|
|
76
|
+
// Module scope on purpose: this must exist before createApp() runs, because it
|
|
77
|
+
// is re-exported for the conformance tests. A nested copy here previously left
|
|
78
|
+
// the export binding undefined and threw on require.
|
|
79
|
+
function normalizeDecision(decision) {
|
|
80
|
+
const v = typeof decision === 'string' ? decision.trim().toLowerCase() : '';
|
|
81
|
+
if (v === 'session') return 'session';
|
|
82
|
+
if (v === 'once' || v === 'allow' || v === 'yes' || v === 'approve' || v === 'approved') {
|
|
83
|
+
return 'once';
|
|
84
|
+
}
|
|
85
|
+
return 'deny';
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
function createApp(options = {}) {
|
|
69
89
|
const opts = {
|
|
70
90
|
model: null,
|
|
@@ -453,6 +473,8 @@ function createApp(options = {}) {
|
|
|
453
473
|
return Promise.resolve('deny');
|
|
454
474
|
};
|
|
455
475
|
|
|
476
|
+
// Vocabulary bridging lives at module scope (see normalizeDecision) so the
|
|
477
|
+
// same function the tests import is the one the live path calls.
|
|
456
478
|
const onDelta = (chunk) => {
|
|
457
479
|
if (!chunk) return;
|
|
458
480
|
if (chunk.reasoning) {
|
|
@@ -481,7 +503,7 @@ function createApp(options = {}) {
|
|
|
481
503
|
const decide = p.approval || approvalPrompter || denyNoPrompter;
|
|
482
504
|
Promise.resolve(decide(info))
|
|
483
505
|
.catch(() => 'deny')
|
|
484
|
-
.then((decision) => engine.respondApproval(info.id, decision));
|
|
506
|
+
.then((decision) => engine.respondApproval(info.id, normalizeDecision(decision)));
|
|
485
507
|
}
|
|
486
508
|
};
|
|
487
509
|
|
|
@@ -1366,4 +1388,4 @@ function createApp(options = {}) {
|
|
|
1366
1388
|
};
|
|
1367
1389
|
}
|
|
1368
1390
|
|
|
1369
|
-
module.exports = { createApp, VERSION };
|
|
1391
|
+
module.exports = { createApp, VERSION, normalizeDecision };
|
package/src/chatflow.js
CHANGED
|
@@ -298,6 +298,27 @@ function shortcutsGrid(cols, ctx) {
|
|
|
298
298
|
];
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The engine's approval vocabulary — and the ONLY tokens that survive
|
|
303
|
+
* `respondApproval` (see cli/vendor/desktop/lib/local/engine.js). It accepts
|
|
304
|
+
* 'once' and 'session' and coerces EVERYTHING ELSE to 'deny'.
|
|
305
|
+
*
|
|
306
|
+
* This overlay used to answer 'allow': the desktop renderer's word for "yes",
|
|
307
|
+
* not the engine's. So a user who pressed "1. Yes" was denied anyway — silently,
|
|
308
|
+
* on every mutating tool, every time. The model then read "the user denied the
|
|
309
|
+
* request" and retried with a different command, which was denied too, and the
|
|
310
|
+
* turn burned several rounds of tokens to accomplish nothing. Emitting the
|
|
311
|
+
* engine's own words is the whole fix; `test/cli-approval.test.mjs` pins it so
|
|
312
|
+
* the two vocabularies cannot drift apart again.
|
|
313
|
+
*/
|
|
314
|
+
const APPROVE_ONCE = 'once';
|
|
315
|
+
const APPROVE_SESSION = 'session';
|
|
316
|
+
const DENY_TOKEN = 'deny';
|
|
317
|
+
|
|
318
|
+
/** Choice labels, in overlay order. Index maps 1:1 onto DECISIONS. */
|
|
319
|
+
const APPROVAL_CHOICES = ['Yes', 'Yes, allow for this session', 'No'];
|
|
320
|
+
const APPROVAL_DECISIONS = [APPROVE_ONCE, APPROVE_SESSION, DENY_TOKEN];
|
|
321
|
+
|
|
301
322
|
/** The tool-approval dialog body. */
|
|
302
323
|
function confirmLines(overlay, cols, ctx) {
|
|
303
324
|
const t = themeOf(ctx);
|
|
@@ -322,8 +343,13 @@ function confirmLines(overlay, cols, ctx) {
|
|
|
322
343
|
const left = active ? span(t.lavender, GLYPH.cursor) : span('', ' ');
|
|
323
344
|
return [left, span(t.gray, ` ${i + 1}. `), span(active ? t.lavender : t.white, label)];
|
|
324
345
|
};
|
|
325
|
-
|
|
326
|
-
|
|
346
|
+
const toolName = overlay.name || 'this tool';
|
|
347
|
+
APPROVAL_CHOICES.forEach((label, i) => {
|
|
348
|
+
// The session option names the tool it will stop asking about, so the
|
|
349
|
+
// blanket allow is never something the user has to infer.
|
|
350
|
+
const text = i === 1 ? `Yes, and allow ${toolName} for this session` : label;
|
|
351
|
+
lines.push(opt(i, text));
|
|
352
|
+
});
|
|
327
353
|
lines.push([span('', '')]);
|
|
328
354
|
lines.push([span(t.gray, 'Esc to cancel')]);
|
|
329
355
|
return lines;
|
|
@@ -815,34 +841,28 @@ async function runSession(host) {
|
|
|
815
841
|
overlay = { type: 'confirm', name: info.tool || info.name || 'tool', args: info.args || {}, sel: 0, info };
|
|
816
842
|
render();
|
|
817
843
|
(async () => {
|
|
844
|
+
const choose = (decision) => {
|
|
845
|
+
overlay = null;
|
|
846
|
+
render();
|
|
847
|
+
resolve(decision);
|
|
848
|
+
};
|
|
818
849
|
for (;;) {
|
|
819
850
|
const key = await nextKey();
|
|
820
851
|
if (key.name === KEY.UP || key.name === KEY.DOWN || key.name === KEY.TAB) {
|
|
821
|
-
|
|
852
|
+
const step = key.name === KEY.UP ? -1 : 1;
|
|
853
|
+
overlay.sel = (overlay.sel + step + APPROVAL_CHOICES.length) % APPROVAL_CHOICES.length;
|
|
822
854
|
render();
|
|
823
855
|
} else if (key.name === KEY.ENTER) {
|
|
824
|
-
|
|
825
|
-
overlay = null;
|
|
826
|
-
render();
|
|
827
|
-
resolve(yes ? 'allow' : 'deny');
|
|
856
|
+
choose(APPROVAL_DECISIONS[overlay.sel] || DENY_TOKEN);
|
|
828
857
|
return;
|
|
829
858
|
} else if (key.name === KEY.ESC || key.name === KEY.CTRL_C) {
|
|
830
|
-
|
|
831
|
-
render();
|
|
832
|
-
resolve('deny');
|
|
859
|
+
choose(DENY_TOKEN);
|
|
833
860
|
return;
|
|
834
861
|
} else if (key.name === 'char') {
|
|
835
862
|
const ch = String(key.ch).trim();
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
resolve('allow');
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
|
-
if (ch === '2') {
|
|
843
|
-
overlay = null;
|
|
844
|
-
render();
|
|
845
|
-
resolve('deny');
|
|
863
|
+
const idx = Number(ch) - 1;
|
|
864
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < APPROVAL_DECISIONS.length) {
|
|
865
|
+
choose(APPROVAL_DECISIONS[idx]);
|
|
846
866
|
return;
|
|
847
867
|
}
|
|
848
868
|
}
|
|
@@ -1765,6 +1785,10 @@ module.exports = {
|
|
|
1765
1785
|
statusLine,
|
|
1766
1786
|
shortcutsGrid,
|
|
1767
1787
|
confirmLines,
|
|
1788
|
+
// The approval vocabulary, exported so a test can assert the CLI only ever
|
|
1789
|
+
// answers with tokens the engine actually honours (see APPROVAL_CHOICES).
|
|
1790
|
+
APPROVAL_CHOICES,
|
|
1791
|
+
APPROVAL_DECISIONS,
|
|
1768
1792
|
rowLines,
|
|
1769
1793
|
transcriptLines,
|
|
1770
1794
|
inputPreviewText,
|
|
@@ -346,6 +346,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
346
346
|
// In-memory only, on purpose — never persisted, so a restart (or
|
|
347
347
|
// newChat()'s clearSessionApprovals) always starts from a clean gate.
|
|
348
348
|
const sessionAllowlists = new Map(); // rootSessionId -> Set<toolName>
|
|
349
|
+
// Denials are remembered for the same reason allows are, mirroring it for
|
|
350
|
+
// the other answer. Without this, a "Deny" was forgotten the instant it was
|
|
351
|
+
// given: the model read `… the user denied the request`, re-planned, called
|
|
352
|
+
// the SAME tool again, and the gate raised a SECOND card — so one "no" cost
|
|
353
|
+
// the user a prompt per round for up to maxRounds (24 chat / 40 autonomous)
|
|
354
|
+
// rounds, each round re-sending the whole conversation to the provider. A
|
|
355
|
+
// gate that only remembers "yes" turns a single click into a retry storm;
|
|
356
|
+
// remembering "no" makes the first answer stick.
|
|
357
|
+
const sessionDenials = new Map(); // rootSessionId -> Set<toolName>
|
|
349
358
|
const pendingApprovals = new Map(); // approvalId -> { resolve }
|
|
350
359
|
|
|
351
360
|
function sessionAllows(rootId, name) {
|
|
@@ -358,10 +367,44 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
358
367
|
sessionAllowlists.get(rootId).add(name);
|
|
359
368
|
}
|
|
360
369
|
|
|
370
|
+
/** Has this conversation already refused this tool? Checked before the card
|
|
371
|
+
* is raised, so a repeat call is refused outright instead of re-prompting. */
|
|
372
|
+
function sessionDenies(rootId, name) {
|
|
373
|
+
const set = sessionDenials.get(rootId);
|
|
374
|
+
return Boolean(set && set.has(name));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function denyForSession(rootId, name) {
|
|
378
|
+
if (!sessionDenials.has(rootId)) sessionDenials.set(rootId, new Set());
|
|
379
|
+
sessionDenials.get(rootId).add(name);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The text a refused tool hands back to the model. The old one-line
|
|
384
|
+
* `"<tool> was not executed — the user denied the request."` read to a
|
|
385
|
+
* capable agent as a transient failure to route around: it would apologise,
|
|
386
|
+
* pick a different command that does the same thing, and call the gate
|
|
387
|
+
* again. This says the durable part out loud (the refusal covers the rest of
|
|
388
|
+
* the conversation, not just that call) and asks for the one response that
|
|
389
|
+
* actually helps — say what you need and stop, so the user can re-enable it.
|
|
390
|
+
*/
|
|
391
|
+
function denialText(name) {
|
|
392
|
+
return (
|
|
393
|
+
`${name} was not executed — the user denied this tool for this conversation. ` +
|
|
394
|
+
`Do NOT retry it and do NOT attempt the same effect by another route ` +
|
|
395
|
+
`(another command, a writeFile instead of an edit, a subagent). ` +
|
|
396
|
+
`Stop calling tools and reply in plain text: say what you were trying to do, ` +
|
|
397
|
+
`what you need, and that the user can re-enable ${name} to let it proceed.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
361
401
|
/** newChat() in the renderer calls this so a fresh conversation never
|
|
362
|
-
* inherits a prior thread's blanket allows.
|
|
402
|
+
* inherits a prior thread's blanket allows — or its refusals. A new chat is
|
|
403
|
+
* a new gate in both directions: leaving denials behind would silently
|
|
404
|
+
* refuse a tool in a thread where the user never said no. */
|
|
363
405
|
function clearSessionApprovals(rootSessionId) {
|
|
364
406
|
sessionAllowlists.delete(rootSessionId);
|
|
407
|
+
sessionDenials.delete(rootSessionId);
|
|
365
408
|
return { ok: true };
|
|
366
409
|
}
|
|
367
410
|
|
|
@@ -379,22 +422,28 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
379
422
|
|
|
380
423
|
/**
|
|
381
424
|
* Ask the renderer to approve one mutating tool call. Resolves 'once',
|
|
382
|
-
* 'session' or 'deny'
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
425
|
+
* 'session' or 'deny' (an explicit choice by the user) — or 'cancel' when
|
|
426
|
+
* nobody ever answered: no listener able to reply, or the turn was aborted.
|
|
427
|
+
* 'cancel' is kept apart from 'deny' on purpose. Both refuse the call, but
|
|
428
|
+
* only 'deny' is a decision the user made, so only 'deny' may be remembered
|
|
429
|
+
* as "this conversation said no" (see sessionDenials). Folding the two
|
|
430
|
+
* together — which is what the old fail-safe did, resolving 'deny' for an
|
|
431
|
+
* abort — would let a cancelled turn permanently refuse a tool the user
|
|
432
|
+
* never ruled on. Sent over `rootOnDelta` (see chat()) as an `{ approval }`
|
|
433
|
+
* chunk so it rides the exact same streaming channel as tool-activity
|
|
434
|
+
* chunks — no new IPC surface needed on the push side, only on the reply
|
|
435
|
+
* side (respondApproval). Fails safe: an unanswered request refuses the
|
|
436
|
+
* call instead of hanging the tool round forever.
|
|
388
437
|
*/
|
|
389
438
|
function requestApproval(rootSessionId, rootOnDelta, signal, info) {
|
|
390
439
|
return new Promise((resolve) => {
|
|
391
440
|
if (signal && signal.aborted) {
|
|
392
|
-
resolve('
|
|
441
|
+
resolve('cancel');
|
|
393
442
|
return;
|
|
394
443
|
}
|
|
395
444
|
const id = randomUUID();
|
|
396
445
|
let settled = false;
|
|
397
|
-
const onAbort = () => finish('
|
|
446
|
+
const onAbort = () => finish('cancel');
|
|
398
447
|
const finish = (decision) => {
|
|
399
448
|
if (settled) return;
|
|
400
449
|
settled = true;
|
|
@@ -405,7 +454,7 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
405
454
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
|
406
455
|
pendingApprovals.set(id, { resolve: finish });
|
|
407
456
|
if (typeof rootOnDelta !== 'function') {
|
|
408
|
-
finish('
|
|
457
|
+
finish('cancel');
|
|
409
458
|
return;
|
|
410
459
|
}
|
|
411
460
|
rootOnDelta({
|
|
@@ -440,6 +489,13 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
440
489
|
if (!T.MUTATING_TOOLS.has(name)) return T.executeTool(name, args, toolCtx);
|
|
441
490
|
if (!confirmModeEnabled()) return T.executeTool(name, args, toolCtx);
|
|
442
491
|
if (sessionAllows(rootSessionId, name)) return T.executeTool(name, args, toolCtx);
|
|
492
|
+
// Already refused in this conversation: refuse again WITHOUT raising a
|
|
493
|
+
// second card. Before this, the model's retry after a denial re-prompted
|
|
494
|
+
// the user for the same tool — one "no" produced a card per round for up
|
|
495
|
+
// to maxRounds rounds, each one a billed provider call re-sending the
|
|
496
|
+
// whole conversation. Checked after the confirm-mode short-circuit so
|
|
497
|
+
// turning the gate off still overrides an earlier refusal.
|
|
498
|
+
if (sessionDenies(rootSessionId, name)) return { ok: false, error: denialText(name) };
|
|
443
499
|
|
|
444
500
|
let preview = null;
|
|
445
501
|
if (name === 'writeFile' || name === 'editFile') {
|
|
@@ -453,8 +509,15 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
|
|
|
453
509
|
diff: preview && preview.diff,
|
|
454
510
|
});
|
|
455
511
|
|
|
512
|
+
// Only a click is remembered. 'cancel' (aborted turn / nobody able to
|
|
513
|
+
// answer) refuses this call but must not write a durable "no" the user
|
|
514
|
+
// never gave.
|
|
456
515
|
if (decision === 'deny') {
|
|
457
|
-
|
|
516
|
+
denyForSession(rootSessionId, name);
|
|
517
|
+
return { ok: false, error: denialText(name) };
|
|
518
|
+
}
|
|
519
|
+
if (decision !== 'session' && decision !== 'once') {
|
|
520
|
+
return { ok: false, error: `${name} was not executed — the request was cancelled.` };
|
|
458
521
|
}
|
|
459
522
|
if (decision === 'session') allowForSession(rootSessionId, name);
|
|
460
523
|
|