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 CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "aegiscode",
3
3
  "productName": "AEGIS Code",
4
- "version": "6.5.5",
5
- "description": "aegiscode 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.",
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
- lines.push(opt(0, 'Yes'));
326
- lines.push(opt(1, 'No'));
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
- overlay.sel = 1 - overlay.sel;
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
- const yes = overlay.sel === 0;
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
- overlay = null;
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
- if (ch === '1') {
837
- overlay = null;
838
- render();
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'. Sent over `rootOnDelta` (see chat()) as an
383
- * `{ approval }` chunk so it rides the exact same streaming channel as
384
- * tool-activity chunks no new IPC surface needed on the push side, only
385
- * on the reply side (respondApproval). Fails safe: no listener able to
386
- * ever answer (no onDelta, or the turn was aborted) resolves 'deny'
387
- * instead of hanging the tool round forever.
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('deny');
441
+ resolve('cancel');
393
442
  return;
394
443
  }
395
444
  const id = randomUUID();
396
445
  let settled = false;
397
- const onAbort = () => finish('deny');
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('deny');
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
- return { ok: false, error: `${name} was not executed — the user denied the request.` };
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