aegis-desktop 0.6.0 → 0.7.0

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/README.md CHANGED
@@ -45,6 +45,88 @@ queue that flushes on each "Sync now" or heartbeat retry. The **remember**
45
45
  button on any assistant reply pins that message to cross-machine memory —
46
46
  queued locally if you're offline.
47
47
 
48
+ ## Autonomous queue
49
+
50
+ The unattended work queue, shared with the CLI: tasks are appended to
51
+ `~/.aegiscode/queue.jsonl` and drained later, one at a time, with tool approval
52
+ **disabled** — there is nobody there to click an approval card. The desktop
53
+ sidebar's queue card and `aegiscode autonomous` are two views of the same file.
54
+
55
+ ```bash
56
+ aegiscode autonomous add "fix the flaky retry test" --cwd ~/repo --commit
57
+ aegiscode autonomous list
58
+ aegiscode autonomous run # drain one task, then stop
59
+ aegiscode autonomous proceed --max 3 # drain up to three
60
+ aegiscode autonomous reconcile --auto # queue the next unfinished PLAN.md phase, then drain it
61
+ aegiscode autonomous retry <id> # put a finished task back
62
+ aegiscode autonomous clear --all # empty the queue
63
+ ```
64
+
65
+ A task's text is stored verbatim — `add "fix --json in the parser"` is a task,
66
+ not a flag. Drains commit only the paths that task's own tool layer wrote, so a
67
+ drain never sweeps a peer's in-flight edits into your commit.
68
+
69
+ ### Aegis Cloud only
70
+
71
+ A queued task runs on the pooled brain — **`nexus-brain`** (alias
72
+ `aegis-brain`) — for the same reason the Claude Code plugin is cloud-only: the
73
+ queue hands work to a loop with no human in it, and the pooled class is the one
74
+ the server can route, budget, and bill on its own. A task that *states* another
75
+ model id is refused where you can still see it, instead of failing minutes into
76
+ a drain as an opaque server error:
77
+
78
+ | Where the model was stated | What happens |
79
+ |---|---|
80
+ | `autonomous add --model <id>` | refused, with the reason, at add time — nothing is queued |
81
+ | A hand-edited `queue.jsonl` | refused pre-flight by the worker (`ms: 0`), before any turn is billed |
82
+ | `AEGIS_MODEL=<id>` in the environment | **ignored for queue runs**, and reported as a note — that variable is shared with the interactive surfaces, which *do* run direct providers |
83
+ | nothing stated | `nexus-brain` |
84
+
85
+ ### What a queued task costs
86
+
87
+ The queue has two shapes, and the cheap one is the default:
88
+
89
+ | | Single pass (default) | Fan-out (opt in) |
90
+ |---|---|---|
91
+ | Provider calls | 1 | `workers` + 1 (investigation passes, then synthesis) |
92
+ | Effort rung | `medium` | `high` |
93
+ | How to ask for it | nothing — it is the default | `AEGIS_AUTONOMOUS_FANOUT=1` |
94
+
95
+ An earlier version sent **every** queued task as a fan-out at the priciest rung:
96
+ one queued line could become several reasoning calls plus a synthesis, all at
97
+ `high`. Making the fan-out opt-in, and letting effort follow the shape of the
98
+ task rather than always topping out, removes the worker multiplication and
99
+ roughly halves the budget on a one-line task. `--effort` (or
100
+ `AEGIS_AUTONOMOUS_EFFORT`) still wins outright, and `--workers N` is only sent
101
+ when you are actually fanning out.
102
+
103
+ > **Known gap:** there is no `--fanout` flag yet — the opt-in is the environment
104
+ > variable or `singlePass: false` in the task record. `--single-pass` still
105
+ > parses, but it now agrees with the default instead of overriding it.
106
+
107
+ ### A turn that runs out of rounds keeps its work
108
+
109
+ The tool loop runs against a round horizon: 24 rounds for an interactive turn
110
+ (`AEGIS_CHAT_MAX_ROUNDS`), 40 for a queued one
111
+ (`AEGIS_AUTONOMOUS_MAX_ROUNDS`). A model that reached it mid-turn used to lose
112
+ everything it had assembled, because the cap was *turn* state.
113
+
114
+ The horizon is now **session state**, held in `lib/local/session-rounds.js`. When
115
+ a turn stops at the cap the interruption is filed against the session, and the
116
+ next message in that conversation is prefixed with a continuation preamble —
117
+ *cut off after N tool rounds; continue, do not restart* — along with
118
+ `max(4, ⌈horizon/4⌉)` extra rounds so re-orientation does not eat the new
119
+ horizon. The resume is announced in the transcript, so it is visible rather
120
+ than silent.
121
+
122
+ - In-memory and **process-local** (30-minute TTL, 64 entries) — it never leaves
123
+ the process and never touches disk.
124
+ - Claiming an entry **consumes** it: one resume per interruption, so a chain of
125
+ interruptions is a chain of deliberate asks, never an automatic loop.
126
+ - A **stated** horizon always wins; the ledger only pads its own default.
127
+ - A caller that mints a fresh session key every turn adopts the most recent held
128
+ entry (bounded to 10 minutes) instead of losing the work.
129
+
48
130
  ## Keyboard shortcuts
49
131
 
50
132
  ### In the main window
@@ -124,6 +206,9 @@ main.js Electron main process — window + IPC shell only
124
206
  preload.js Context-isolated IPC bridge exposed to the renderer
125
207
  renderer/ UI (vanilla JS, no framework)
126
208
  lib/local/ Model classes, providers, agentic tool loop, prompt
209
+ lib/local/queue.js The shared work queue (~/.aegiscode/queue.jsonl)
210
+ lib/local/autonomous.js The unattended worker — directive, digest, commits
211
+ lib/local/session-rounds.js Session-scoped tool-round ledger (in-memory)
127
212
  lib/sync/ Local session/memory persistence + sync queue
128
213
  vendor/aegis.js The AEGIS transport client (thin — no engine logic)
129
214
  bin/aegis.js `aegis` CLI entry point for the global npm install
@@ -55,6 +55,15 @@ const gitScope = require('./git-scope.js');
55
55
  /** Default tool-round horizon for an unattended turn (matches the engine's). */
56
56
  const DEFAULT_ROUNDS = 40;
57
57
 
58
+ /**
59
+ * Consecutive tool-round-horizon stops a queued task gets before runOne gives
60
+ * up on it as unable to converge and settles it 'error' instead of 'pending'.
61
+ * Without this bound a task that never finishes would keep proceed()'s drain
62
+ * loop picking it back up forever — proceed() defaults to no --max at all, so
63
+ * nothing else would ever stop it.
64
+ */
65
+ const MAX_ROUND_STOPS = 3;
66
+
58
67
  /**
59
68
  * The engine tools that report the file they write, and the argument holding
60
69
  * it. This set is the attribution record for a task's commit: `exec` is absent
@@ -73,13 +82,65 @@ function writtenPath(tool) {
73
82
 
74
83
  /**
75
84
  * The default AEGIS Cloud model for autonomous work: the pooled brain, which
76
- * is the tier the server fans out to multiple reasoning workers and
77
- * synthesises. Autonomous tasks are exactly the ones worth that spend, and
78
- * `nexus-brain` is the canonical id the catalog itself prefers (the other tier
79
- * spellings are aliases of it — see filterAegisCatalog in engine.js).
85
+ * is the tier the server can fan out to multiple reasoning workers and
86
+ * synthesise. `nexus-brain` is the canonical id the catalog itself prefers
87
+ * (the other tier spellings are aliases of it — see filterAegisCatalog in
88
+ * engine.js).
89
+ *
90
+ * The model id is only the tier. It is NOT what makes an autonomous task
91
+ * expensive — the fan-out is, and the fan-out is opt-in per task (see
92
+ * resolveFanout below). This used to be documented the other way round ("the
93
+ * pooled brain ... autonomous tasks are exactly the ones worth that spend"),
94
+ * and the worker sent `autonomous: true` on every queued task, so the most
95
+ * expensive shape of the most expensive tier ran on one-line tasks too.
80
96
  */
81
97
  const DEFAULT_MODEL = 'nexus-brain';
82
98
 
99
+ /**
100
+ * THE QUEUE RUNS ON AEGIS CLOUD, AND NOTHING ELSE.
101
+ *
102
+ * A queued task is billed to the AEGIS pool and every turn it makes goes out
103
+ * with `class: 'aegis'` (see runTask below) — the pool is the only backend this
104
+ * worker can reach. So a model id here has to be one the *pool* serves. A
105
+ * direct-provider id is not a cheaper option the queue could fall back to; it
106
+ * is a request the pool cannot honour, or worse, a per-provider spelling
107
+ * (`anthropic`, `groq`, …) that quietly pins one upstream instead of letting
108
+ * the pool auto-route across whichever providers hold a live key.
109
+ *
110
+ * The accept-list is therefore the pooled-brain tier family — the one entry
111
+ * engine.js's filterAegisCatalog offers for the Aegis Cloud class, plus the
112
+ * `-smart`/`-neo` tier spellings the server still serves as aliases of it. This
113
+ * mirrors selectBrainEntry() there rather than re-deriving "anything starting
114
+ * with nexus-": `nexus-fast` is not a tier the catalog has ever served, and
115
+ * accepting a made-up id means a queued task that fails at the server after
116
+ * being picked up, or runs on a tier nobody chose.
117
+ */
118
+ const AEGIS_MODEL_RE = /^(?:nexus|aegis)-brain(?:-(?:smart|neo))?$/;
119
+ const AEGIS_MODEL_IDS = Object.freeze(['nexus-brain', 'aegis-brain']);
120
+
121
+ /** True when `id` names an AEGIS Cloud pooled-brain tier (the queue's only models). */
122
+ function isAegisModel(id) {
123
+ return AEGIS_MODEL_RE.test(String(id == null ? '' : id).trim());
124
+ }
125
+
126
+ /**
127
+ * Why a STATED model id cannot be queued, or '' when it can. Blank is not a
128
+ * refusal — "no pick" is the default model, which resolveModel supplies.
129
+ *
130
+ * The message names the pool, because the failure it prevents ("queued on
131
+ * claude-sonnet-4, ran on — or was billed to — something else") is invisible
132
+ * otherwise: `class: 'aegis'` would be sent with an id the server does not
133
+ * serve, and the task would come back as an opaque error minutes later.
134
+ */
135
+ function modelRefusal(id) {
136
+ const stated = String(id == null ? '' : id).trim();
137
+ if (!stated || isAegisModel(stated)) return '';
138
+ return (
139
+ `the autonomous queue runs Aegis Cloud models only (${DEFAULT_MODEL}, ` +
140
+ `${AEGIS_MODEL_IDS.join('/')} aliases); "${stated}" is not one`
141
+ );
142
+ }
143
+
83
144
  /** Phrase match for "work autonomously" in a prompt or a queued task. */
84
145
  const AUTONOMOUS_REQUEST_RE =
85
146
  /\bautonomously\b|\bon your own\b|\bwithout asking\b|\bend[- ]to[- ]end\b|\bno (?:more )?questions\b|\bfully autonomous\b|\bqueue it\b/i;
@@ -160,23 +221,92 @@ function withRoundHorizon(rounds, env, fn) {
160
221
  }
161
222
 
162
223
  /**
163
- * The model an autonomous task runs on: an explicit pick wins, then the
164
- * environment (so a systemd timer can pin a cheap tier), then the pooled
165
- * brain. Never the interactive session's model — a queue survives the session
166
- * that queued it, so it cannot inherit that session's choice.
224
+ * The model an autonomous task runs on: an explicit pick wins, then an AEGIS
225
+ * Cloud pin in the environment (so a systemd timer can choose a tier), then the
226
+ * pooled brain. Never the interactive session's model — a queue survives the
227
+ * session that queued it, so it cannot inherit that session's choice.
228
+ *
229
+ * TWO DIFFERENT TREATMENTS FOR TWO DIFFERENT SOURCES, on purpose:
230
+ *
231
+ * - a pick that came from the TASK is returned verbatim, even when it is
232
+ * wrong. Substituting a correct model for a stated one is how a queue
233
+ * "runs on nexus-brain" while the file says otherwise; the caller refuses
234
+ * it out loud instead (modelRefusal, queue.addTask, and the pre-flight in
235
+ * runTask).
236
+ * - a non-Aegis value in the ENVIRONMENT is skipped, because AEGIS_MODEL is
237
+ * shared with the interactive surfaces (which run direct providers), so a
238
+ * stray value there is not a statement about the queue. Refusing every
239
+ * task over it would break drains for a reason that is not the task's
240
+ * fault; a fallback to the pooled brain keeps the drain honest and on-cloud.
167
241
  */
168
242
  function resolveModel({ model, env } = {}) {
169
243
  const e = env || process.env;
170
244
  const picked = String(model || '').trim();
171
245
  if (picked) return picked;
172
246
  const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
173
- return fromEnv || DEFAULT_MODEL;
247
+ return isAegisModel(fromEnv) ? fromEnv : DEFAULT_MODEL;
174
248
  }
175
249
 
176
- /** Effort rung for an unattended turn: high unless the caller/environment says otherwise. */
177
- function resolveEffort({ effort, env } = {}) {
250
+ /**
251
+ * The environment pin the queue had to ignore, or '' when there was none.
252
+ *
253
+ * Reported (not silent) because an operator who exported
254
+ * AEGIS_AUTONOMOUS_MODEL=deepseek-v4-flash asked for a model and is not getting
255
+ * it: the queue falls back to the pool, and the one place that says so is this
256
+ * string, which runTask emits as a note and the desktop card shows.
257
+ */
258
+ function ignoredEnvModel({ model, env } = {}) {
259
+ if (String(model || '').trim()) return ''; // the task's own pick is what counts
260
+ const e = env || process.env;
261
+ const fromEnv = String(e.AEGIS_AUTONOMOUS_MODEL || e.AEGIS_MODEL || '').trim();
262
+ if (!fromEnv || isAegisModel(fromEnv)) return '';
263
+ const which = String(e.AEGIS_AUTONOMOUS_MODEL || '').trim() ? 'AEGIS_AUTONOMOUS_MODEL' : 'AEGIS_MODEL';
264
+ return `${which}="${fromEnv}" is not an Aegis Cloud model — running on ${DEFAULT_MODEL} instead`;
265
+ }
266
+
267
+ /**
268
+ * Whether a queued task runs the pooled-brain worker fan-out (aegis1
269
+ * services/pool_brain.py) or one plain turn on the same tier.
270
+ *
271
+ * COST IS THE REASON THIS IS OPT-IN. The fan-out is the single biggest
272
+ * multiplier this app can put on a bill: pool_brain spawns up to `workers`
273
+ * reasoning workers plus a synthesis pass, re-sends the task context to every
274
+ * one of them, and sizes each from the same effort ladder. A 3-worker
275
+ * high-effort task is therefore roughly four full reasoning calls against a
276
+ * 65536-token ladder, where the identical task single-pass is one call on the
277
+ * medium rung. The fan-out earns that on genuinely open-ended investigation
278
+ * ("why did X regress across this repo"); it is pure waste on a task that
279
+ * already names the file to edit.
280
+ *
281
+ * Precedence: the task's own `autonomous: true` (or `singlePass: false`, the
282
+ * explicit "fan me out") wins, then AEGIS_AUTONOMOUS_FANOUT=1 in the
283
+ * environment, else single pass.
284
+ */
285
+ function resolveFanout(item = {}, env = process.env) {
286
+ const it = item || {};
287
+ if (it.autonomous === true || it.singlePass === false) return true;
178
288
  const e = env || process.env;
179
- return String(effort || e.AEGIS_AUTONOMOUS_EFFORT || 'high');
289
+ return /^(1|true|yes|on)$/i.test(String(e.AEGIS_AUTONOMOUS_FANOUT || '').trim());
290
+ }
291
+
292
+ /**
293
+ * Effort rung for an unattended turn.
294
+ *
295
+ * The rung is a spend knob, not a quality slider: the pooled class sizes its
296
+ * whole budget ladder from it (aegis1 services/pool_brain.py pass_budgets:
297
+ * low/medium/high -> 16384/32768/65536 tokens TOTAL across the fan-out), and
298
+ * the engine uses it for any model that reasons against its own output budget.
299
+ * `high` is the right rung for a fan-out — it is what buys a synthesis pass
300
+ * worth reading — but on a single pass it is a 2x over medium for budget
301
+ * nobody reads, so the default follows the shape of the task rather than
302
+ * always being the most expensive rung. An explicit pick (item.effort) or
303
+ * AEGIS_AUTONOMOUS_EFFORT still wins outright.
304
+ */
305
+ function resolveEffort({ effort, env, fanout } = {}) {
306
+ const e = env || process.env;
307
+ const stated = String(effort || e.AEGIS_AUTONOMOUS_EFFORT || '').trim();
308
+ if (stated) return stated;
309
+ return fanout ? 'high' : 'medium';
180
310
  }
181
311
 
182
312
  /**
@@ -205,7 +335,8 @@ function autonomousDirective(rounds = DEFAULT_ROUNDS) {
205
335
  */
206
336
  function digestLine(item, result) {
207
337
  const task = String(item.task || '').replace(/\s+/g, ' ').trim().slice(0, 120);
208
- const mark = result && result.ok ? '✓' : '✗';
338
+ const paused = Boolean(result && result.ok && result.stoppedOnRounds);
339
+ const mark = paused ? '⏸' : result && result.ok ? '✓' : '✗';
209
340
  const files = (result && Array.isArray(result.files) && result.files.slice(0, 6)) || [];
210
341
  const where = files.length ? ` — touched: ${files.join(', ')}${result.files.length > files.length ? ', …' : ''}` : '';
211
342
  const why = !result || result.ok ? '' : ` — ${String((result && result.error) || 'failed').slice(0, 120)}`;
@@ -290,8 +421,24 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
290
421
  */
291
422
  async function runTask(item, { carry = '', commit } = {}) {
292
423
  const cwd = item.cwd || process.cwd();
424
+ // Aegis Cloud or nothing — checked BEFORE the turn, not at the server. An
425
+ // item whose model is not a pooled tier (a hand-edited queue file, a
426
+ // `--model` the CLI accepted before this rule existed, another host's
427
+ // older build) would otherwise go out as `class: 'aegis'` with an id the
428
+ // pool does not serve: billed work if it happens to be a per-provider
429
+ // spelling, an opaque server error otherwise. Failing here names the model
430
+ // and the allowed ones, and costs nothing.
431
+ const refusal = modelRefusal(item.model);
432
+ if (refusal) {
433
+ const failed = { ok: false, error: refusal, model: item.model, ms: 0 };
434
+ emit({ type: 'finish', taskId: item.id, ok: false, result: failed });
435
+ return failed;
436
+ }
293
437
  const model = resolveModel({ model: item.model, env });
294
- const effort = resolveEffort({ effort: item.effort, env });
438
+ const ignored = ignoredEnvModel({ model: item.model, env });
439
+ if (ignored) emit({ type: 'note', taskId: item.id, note: ignored });
440
+ const fanout = resolveFanout(item, env);
441
+ const effort = resolveEffort({ effort: item.effort, env, fanout });
295
442
  const rounds = maxRounds(env, item.maxRounds);
296
443
  // Approval requests have no one to answer them here; see the header.
297
444
  let approvalAsked = null;
@@ -331,7 +478,7 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
331
478
  const wantCommit = commit === undefined ? Boolean(item.commit) : Boolean(commit);
332
479
  const before = wantCommit ? safe(() => git.gitStatusSnapshot(cwd), null) : null;
333
480
 
334
- emit({ type: 'start', taskId: item.id, model, cwd, rounds });
481
+ emit({ type: 'start', taskId: item.id, model, cwd, rounds, fanout, effort });
335
482
  const started = now();
336
483
  let result;
337
484
  try {
@@ -341,13 +488,16 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
341
488
  class: 'aegis',
342
489
  model,
343
490
  prompt: taskPrompt(item, { carry, rounds }),
344
- // The pooled brain ("work autonomously" in the GUI): the server fans
345
- // the round out to several reasoning workers and synthesises. A
346
- // `singlePass` task opts out — the retry/write-up passes in the
347
- // engine send `brain: false` for exactly this reason.
348
- autonomous: !item.singlePass,
491
+ // The pooled-brain fan-out ("fan out this turn" in the GUI chat
492
+ // header, and opt-in here): the server fans the round out to
493
+ // several reasoning workers and synthesises. It costs about
494
+ // workers+1 full reasoning calls, so it travels only when the task
495
+ // asked for it — see resolveFanout above. A `singlePass` task is
496
+ // the default for exactly that reason; the retry/write-up passes in
497
+ // the engine send `brain: false` for the same one-call reason.
498
+ autonomous: fanout,
349
499
  effort,
350
- workers: item.workers || undefined,
500
+ workers: fanout ? item.workers || undefined : undefined,
351
501
  // The turn's working directory rides on `env`: engine.js reads the
352
502
  // tool loop's cwd from envFor(payload), so a top-level `cwd` field
353
503
  // is a directory the engine would ignore and every tool would run
@@ -476,13 +626,41 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
476
626
  const items = queue.loadQueue(env);
477
627
  const claimed = queue.markRunning(items, item.id, { now: now() });
478
628
  if (!claimed) return { ok: false, error: `unknown task #${item.id}`, carry };
629
+ const priorRoundStops = Number(claimed.roundStops) || 0;
479
630
  queue.saveQueue(env, items);
480
631
 
481
- const result = await runTask(claimed, { carry, commit });
632
+ const raw = await runTask(claimed, { carry, commit });
633
+
634
+ // A task that hit its tool-round horizon has NOT finished — engine.js's
635
+ // session ledger (session-rounds.js) is holding the rest of the work
636
+ // under this task's stable session id (sessionIdFor(item.id)), ready to
637
+ // inject a continuation preamble the moment this item is dispatched
638
+ // again. Settling it 'done' here (the old behavior) threw that away: the
639
+ // queue believed the task was finished, so nothing ever sent the next
640
+ // turn that would have consumed the resume, and a human had to notice
641
+ // the output was incomplete and re-queue the whole task from scratch.
642
+ //
643
+ // Left 'pending' instead, so proceed()'s own drain loop picks it straight
644
+ // back up — unless it has now failed to converge MAX_ROUND_STOPS times in
645
+ // a row, at which point looping on it forever (an unattended proceed()
646
+ // with no --max has no other cap at all) is worse than a visible error.
647
+ const hitRoundCap = Boolean(raw.ok && raw.stoppedOnRounds);
648
+ const roundStops = hitRoundCap ? priorRoundStops + 1 : 0;
649
+ const exhausted = hitRoundCap && roundStops > MAX_ROUND_STOPS;
650
+ const result = exhausted
651
+ ? {
652
+ ...raw,
653
+ ok: false,
654
+ error:
655
+ `stopped at its tool-round horizon ${roundStops} times in a row without finishing — ` +
656
+ 'raise AEGIS_AUTONOMOUS_MAX_ROUNDS or split the task into smaller ones',
657
+ }
658
+ : raw;
659
+ const status = exhausted ? 'error' : hitRoundCap ? 'pending' : result.ok ? 'done' : 'error';
482
660
 
483
661
  const after = queue.loadQueue(env);
484
662
  queue.settle(after, claimed.id, {
485
- status: result.ok ? 'done' : 'error',
663
+ status,
486
664
  result: {
487
665
  ok: result.ok,
488
666
  output: result.output || '',
@@ -494,13 +672,15 @@ function createQueueWorker({ engine, env = process.env, log = () => {}, git = gi
494
672
  error: result.ok ? null : result.error,
495
673
  now: now(),
496
674
  });
675
+ const settled = queue.findTask(after, claimed.id);
676
+ if (settled) settled.roundStops = roundStops;
497
677
  queue.saveQueue(env, after);
498
678
  queue.appendRun(env, {
499
679
  id: claimed.id,
500
680
  task: claimed.task,
501
681
  cwd: claimed.cwd,
502
682
  model: resolveModel({ model: claimed.model, env }),
503
- status: result.ok ? 'done' : 'error',
683
+ status: status === 'pending' ? 'stopped' : status,
504
684
  at: new Date(now()).toISOString(),
505
685
  ms: result.ms,
506
686
  usage: result.usage || null,
@@ -582,13 +762,19 @@ function commitMessage(item) {
582
762
  module.exports = {
583
763
  DEFAULT_MODEL,
584
764
  DEFAULT_ROUNDS,
765
+ MAX_ROUND_STOPS,
766
+ AEGIS_MODEL_IDS,
767
+ isAegisModel,
768
+ modelRefusal,
585
769
  WRITE_TOOLS,
586
770
  writtenPath,
587
771
  isAutonomousRequest,
588
772
  maxRounds,
589
773
  withRoundHorizon,
590
774
  resolveModel,
775
+ ignoredEnvModel,
591
776
  resolveEffort,
777
+ resolveFanout,
592
778
  autonomousDirective,
593
779
  taskPrompt,
594
780
  appendDigest,
@@ -383,7 +383,14 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
383
383
  * settings store's own accessor; then the safe default — ON, i.e. the gate
384
384
  * stays up, so a store that predates the toggle can never silently
385
385
  * disable it. */
386
+ // Session-scoped round accounting (lib/local/session-rounds.js): the
387
+ // tool-round cap is held against the *session* rather than the single turn,
388
+ // so a turn that reaches its horizon is resumed by the next turn in the same
389
+ // conversation instead of being cut off with the work lost. Module-level
390
+ // state, so it survives `send()` and even a fresh engine instance.
391
+ const sessionRounds = require('./session-rounds.js');
386
392
  const confirmModeEnabled = () => {
393
+
387
394
  if (typeof getConfirmMode === 'function') return getConfirmMode() !== false;
388
395
  if (settings && typeof settings.getConfirmMode === 'function') {
389
396
  const value = settings.getConfirmMode();
@@ -938,6 +945,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
938
945
 
939
946
  const system = (payload && payload.system) || buildSystemPrompt(envFor(payload));
940
947
  const history = Array.isArray(payload && payload.messages) ? payload.messages.filter(Boolean).slice() : [];
948
+ // Index into `history` of everything THIS turn added as opposed to what the
949
+ // caller brought with it. Reset after the resume rehydration below, so a
950
+ // restored transcript is never re-recorded as if this turn had produced it
951
+ // (which would double it on every interruption in a chain).
952
+ let historyStart = history.length;
941
953
  let prompt = (payload && payload.prompt) || '';
942
954
 
943
955
  // Lazily start ONE shell session for this turn; the exec tool shares it so
@@ -1020,12 +1032,67 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1020
1032
  // The numbers match aegiscodex-dev's (src/autonomous.js) so both clients
1021
1033
  // behave the same: 24 rounds for a chat turn, 40 for an autonomous one.
1022
1034
  // Env-overridable for a deliberately long job.
1023
- const maxRounds = (() => {
1024
- const name = autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS';
1025
- const raw = Number.parseInt(process.env[name] || '', 10);
1026
- if (Number.isFinite(raw) && raw > 0) return raw;
1027
- return autonomous ? 40 : 24;
1035
+ // A stated horizon (env) still wins outright — an explicit number is the
1036
+ // user overriding the engine, not something the ledger may pad.
1037
+ const roundEnvName = autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS';
1038
+ const statedRounds = (() => {
1039
+ const raw = Number.parseInt(process.env[roundEnvName] || '', 10);
1040
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
1028
1041
  })();
1042
+ const baseRounds = statedRounds || (autonomous ? 40 : 24);
1043
+
1044
+ // Did the previous turn in this session die at its horizon? If so, this
1045
+ // turn is a continuation of it: same job, unfinished, and the model has
1046
+ // to be told — a cold restart is what made the old cap lose work.
1047
+ let roundSessionKey = sessionRounds.keyFor(payload, history);
1048
+ let resumed = sessionRounds.take(roundSessionKey);
1049
+ if (!resumed) {
1050
+ // The caller minted a fresh key this turn (no stated session id and a
1051
+ // rebuilt history array). Adopt the held work rather than dropping it,
1052
+ // but only within the ledger's recency window.
1053
+ const adopted = sessionRounds.adopt();
1054
+ if (adopted) {
1055
+ resumed = adopted.entry;
1056
+ roundSessionKey = adopted.key;
1057
+ }
1058
+ }
1059
+ if (resumed) {
1060
+ const preamble = sessionRounds.resumePreamble(resumed);
1061
+ // Restore the interrupted turn's transcript, because the preamble alone
1062
+ // says HOW MUCH was done, not WHAT. The two callers need different
1063
+ // halves of it, so `session-rounds` files both (see `record`):
1064
+ //
1065
+ // · a caller who brings no conversation of its own — the queue
1066
+ // worker's fresh dispatch; autonomous.js never sends `messages` —
1067
+ // has nothing, so it gets the FULL transcript;
1068
+ // · an interactive chat already carries the prior turns (and the
1069
+ // "stopped at N rounds" note), so it gets only what THIS turn added
1070
+ // that the caller never saw: the tool calls and their results. Full
1071
+ // restore here would duplicate the caller's own text.
1072
+ //
1073
+ // The old `history.length === 0` guard did the first and not the second,
1074
+ // so the resume it was written for a queue task never reached the
1075
+ // interactive sessions this feature is actually used from.
1076
+ if (history.length === 0) {
1077
+ if (Array.isArray(resumed.messages) && resumed.messages.length) history.push(...resumed.messages);
1078
+ } else if (Array.isArray(resumed.added) && resumed.added.length) {
1079
+ history.push(...resumed.added);
1080
+ }
1081
+ // Round 1's ask travels as `prompt`; a follow-up dispatch (and every
1082
+ // turn that skips the shorthand) has to receive it through history.
1083
+ if (prompt) prompt = `${preamble}\n\n${prompt}`;
1084
+ else history.push({ role: 'user', content: preamble });
1085
+ if (rootOnDelta) {
1086
+ rootOnDelta({
1087
+ delta:
1088
+ `\n\n[resuming work this session left unfinished at ${resumed.rounds} tool rounds` +
1089
+ `${resumed.interruptions > 1 ? ` (cut off ${resumed.interruptions}×)` : ''}…]\n`,
1090
+ });
1091
+ }
1092
+ }
1093
+ // A resumed turn gets a small bonus on top of the base horizon so
1094
+ // re-orientation does not consume the new budget before any work lands.
1095
+ const maxRounds = statedRounds || baseRounds + (resumed ? sessionRounds.bonus(baseRounds) : 0);
1029
1096
  let round = 0;
1030
1097
 
1031
1098
  // Token accounting for the whole TURN, not just its last round. An
@@ -1075,6 +1142,11 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1075
1142
  prompt = '';
1076
1143
  };
1077
1144
 
1145
+ // From here on, everything pushed into `history` is this turn's own work:
1146
+ // the restored transcript above (if any) belongs to the turn it came from,
1147
+ // and re-recording it would double the ledger on every interruption.
1148
+ historyStart = history.length;
1149
+
1078
1150
  for (;;) {
1079
1151
  // Stop and SAY so. A turn that reaches its horizon has usually done
1080
1152
  // real work; ending silently would paint an empty answer over it,
@@ -1084,13 +1156,34 @@ function createLocalEngine({ aegis, settings, ollama, providers, tools, promptBu
1084
1156
  const note =
1085
1157
  `[stopped at ${maxRounds} tool rounds` +
1086
1158
  `${turnUsage.total_tokens ? `, ${turnUsage.total_tokens.toLocaleString()} tokens` : ''}` +
1087
- `. Ask again to continue, or raise ` +
1088
- `${autonomous ? 'AEGIS_AUTONOMOUS_MAX_ROUNDS' : 'AEGIS_CHAT_MAX_ROUNDS'}.]`;
1159
+ `. This session holds the work: your next message here resumes it automatically` +
1160
+ ` (with a continuation preamble), or raise ${roundEnvName} for a longer single turn.]`;
1161
+ // File the interruption before returning, so the horizon is a pause
1162
+ // in the session rather than the end of the job. `chain` carries the
1163
+ // count forward across resumes.
1164
+ const held = sessionRounds.record(roundSessionKey, {
1165
+ rounds: round,
1166
+ tokens: turnUsage.total_tokens || 0,
1167
+ note,
1168
+ chain: resumed ? resumed.interruptions : 0,
1169
+ // The actual tool-call/tool-result transcript built up this turn —
1170
+ // everything folded/pushed into `history` by completed rounds — so
1171
+ // a resume can restore real memory of the work, not just a count
1172
+ // of it (see the `resumed.messages` rehydration above).
1173
+ messages: history,
1174
+ // …and only what this turn itself added, for the caller that
1175
+ // already has the rest of `history` in its own conversation.
1176
+ added: history.slice(historyStart),
1177
+ });
1089
1178
  if (rootOnDelta) rootOnDelta({ delta: `\n\n${note}` });
1090
1179
  return withTurnUsage({
1091
1180
  model: base.model,
1092
1181
  choices: [{ message: { content: note }, finish_reason: 'length' }],
1093
1182
  stoppedOnRounds: true,
1183
+ heldForSession: true,
1184
+ heldRounds: held.rounds,
1185
+ sessionInterruptions: held.interruptions,
1186
+ resumedFrom: roundSessionKey,
1094
1187
  });
1095
1188
  }
1096
1189
  round += 1;
@@ -179,17 +179,32 @@ function upsert(items, entry) {
179
179
  * with it as the tool loop's working directory), `model` is the AEGIS Cloud
180
180
  * model id to run it on, and `commit` asks the worker to commit what the task
181
181
  * changed (scoped — see autonomous.js).
182
+ *
183
+ * AEGIS CLOUD ONLY, REFUSED AT THE DOOR. The worker sends every task out as
184
+ * `class: 'aegis'`, so a model the pool does not serve cannot run here at all —
185
+ * it is a task that fails minutes into a drain, after being picked up, with an
186
+ * opaque server error. `model: null` stays legal: that is "no pick", and
187
+ * autonomous.resolveModel fills in the pooled brain. Anything else has to pass
188
+ * that module's own accept-list, so the CLI (`aegiscode autonomous add
189
+ * --model`), the desktop card and a hand-written queue file are all held to the
190
+ * same rule. Required lazily: queue.js is a dependency of autonomous.js, and
191
+ * this check must not turn that into a load-order cycle.
182
192
  */
183
193
  function addTask(env, opts = {}) {
184
194
  const task = String(opts.task == null ? '' : opts.task).trim();
185
195
  if (!task) throw new Error('queue: a task needs text');
196
+ const statedModel = String(opts.model == null ? '' : opts.model).trim();
197
+ if (statedModel) {
198
+ const refusal = require('./autonomous.js').modelRefusal(statedModel);
199
+ if (refusal) throw new Error(`queue: ${refusal}`);
200
+ }
186
201
  const items = loadQueue(env);
187
202
  const now = opts.now || Date.now();
188
203
  const entry = {
189
204
  id: nextId(items),
190
205
  task,
191
206
  cwd: opts.cwd || process.cwd(),
192
- model: opts.model || null,
207
+ model: statedModel || null,
193
208
  effort: opts.effort || null,
194
209
  workers: Number.isInteger(opts.workers) ? opts.workers : null,
195
210
  singlePass: Boolean(opts.singlePass),
@@ -251,6 +266,10 @@ function retryTask(env, id, opts = {}) {
251
266
  item.updated = opts.now || Date.now();
252
267
  delete item.error;
253
268
  delete item.pid;
269
+ // A manual retry is a fresh deliberate ask, so it gets its own full run of
270
+ // MAX_ROUND_STOPS chances (autonomous.js) rather than inheriting whatever
271
+ // count a previous, unrelated failure left behind.
272
+ delete item.roundStops;
254
273
  saveQueue(env, items);
255
274
  return item;
256
275
  }