@nexrall/code-core 1.4.25 → 1.4.27

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.
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = void 0;
36
+ exports.VERIFY_CMD_RE = exports.WRITE_TOOL_NAMES = exports.ToolNotAllowedError = exports._stallLimits = void 0;
37
+ exports.errorRoundSignature = errorRoundSignature;
37
38
  exports.resolveMaxIterations = resolveMaxIterations;
38
39
  exports.createLimiter = createLimiter;
39
40
  exports.extractSubTaskText = extractSubTaskText;
@@ -59,6 +60,8 @@ const executor_1 = require("../tools/executor");
59
60
  const agentTypes_1 = require("./agentTypes");
60
61
  const skills_1 = require("./skills");
61
62
  const rules_1 = require("../permissions/rules");
63
+ const planMode_1 = require("./planMode");
64
+ const agentRegistry_1 = require("./agentRegistry");
62
65
  const sandbox_1 = require("../tools/sandbox");
63
66
  const index_1 = require("../plugins/index");
64
67
  const testIntegrity_1 = require("./testIntegrity");
@@ -187,6 +190,30 @@ const DEFAULT_MAX_ITERATIONS = 500;
187
190
  const MAX_ITERATIONS_CEILING = 2000; // default auto-continue backstop (no explicit opt-in)
188
191
  const HARD_ITERATIONS_CAP = 100000; // absolute safety cap — even explicit opt-in can't exceed this
189
192
  const STALL_LIMIT = 8; // consecutive all-failed tool rounds → give up (runaway guard)
193
+ // Consecutive rounds producing the IDENTICAL error(s) → give up, even if other calls in
194
+ // those rounds succeeded. Higher than STALL_LIMIT because a repeat is weaker evidence of
195
+ // being stuck than a total failure: legitimately retrying one failing command a few times
196
+ // while making progress elsewhere is normal, twelve times is not.
197
+ const REPEAT_STALL_LIMIT = 12;
198
+ /**
199
+ * Fingerprint one round's tool failures, for the repeated-failure runaway guard.
200
+ *
201
+ * Exported (with the limits) purely as a test seam: the guard's whole value is in the
202
+ * edge cases — that a DIFFERENT error each round must NOT trip it, that call order
203
+ * within a round is irrelevant, that a long error body doesn't make every occurrence
204
+ * look unique — and none of that is reachable without driving a live model loop.
205
+ *
206
+ * Sorted so parallel tool calls completing in a different order still compare equal;
207
+ * truncated because errors often embed a varying path or timestamp late in the string.
208
+ */
209
+ function errorRoundSignature(errored) {
210
+ return errored
211
+ .map(({ name, error }) => `${name}:${String(error).slice(0, 200)}`)
212
+ .sort()
213
+ .join('|');
214
+ }
215
+ /** Runaway-guard limits, exposed for tests. */
216
+ exports._stallLimits = { STALL_LIMIT, REPEAT_STALL_LIMIT };
190
217
  // Resolve the soft iteration budget. Precedence:
191
218
  // options.maxIterations → env NEXRALL_MAX_ITERATIONS → settings.maxIterations → default
192
219
  //
@@ -541,7 +568,49 @@ async function runSubTask(input, options, agentTypes) {
541
568
  // ENOENT stats — against a sub-agent that is about to run for seconds to
542
569
  // minutes. Note runAgentLoop re-reads for the sub-agent anyway, so the old
543
570
  // behaviour was already inconsistent: fresh for the child, stale for the lookup.
544
- const requestedType = typeof input.subagent_type === 'string' ? input.subagent_type : '';
571
+ // ── Resolve a resume target ─────────────────────────────────────────────────
572
+ //
573
+ // `resume_agent_id` continues a previous sub-agent. The stored transcript
574
+ // carries the agent's NAME, and that name — not the one the model passed — is
575
+ // what gets authorised below.
576
+ //
577
+ // This matters because an id would otherwise be a permanent capability: deny
578
+ // `task(explorer)` today and a model holding yesterday's explorer id could
579
+ // still resume it, with the deny rule looking like it was applied. Re-deriving
580
+ // the name from storage also stops a mismatched `subagent_type` from being
581
+ // used to launder a denied agent under an allowed name.
582
+ const resumeId = typeof input.resume_agent_id === 'string' ? input.resume_agent_id.trim() : '';
583
+ const resumed = resumeId ? (0, agentRegistry_1.getAgent)(resumeId) : undefined;
584
+ if (resumeId && !resumed) {
585
+ return {
586
+ error: `No resumable sub-agent with id "${resumeId}". Ids live only for the current session and the ` +
587
+ 'oldest are dropped when too many accumulate, so this one has expired or never existed. ' +
588
+ 'Start a fresh sub-task with a self-contained prompt instead.',
589
+ };
590
+ }
591
+ const requestedType = resumed
592
+ ? (resumed.agentName ?? '')
593
+ : (typeof input.subagent_type === 'string' ? input.subagent_type : '');
594
+ // ── Enforce `deny: ["task(<name>)"]` ─────────────────────────────────────────
595
+ //
596
+ // This is the load-bearing check; filtering the catalogue in runAgentLoop only
597
+ // stops the agent being SUGGESTED. It must run BEFORE resolution, because the
598
+ // reload-on-miss path below deliberately re-reads from disk UNFILTERED — a
599
+ // denied agent is absent from the snapshot, would therefore "miss", and would
600
+ // then be found by that reload and run. Denying by omission is not denying.
601
+ //
602
+ // Phrased as a policy refusal, not "unknown type": the model must not respond
603
+ // by trying to create the agent file it thinks is missing.
604
+ if (requestedType) {
605
+ const decision = (0, rules_1.evaluatePermission)((0, rules_1.loadSettings)(options.workDir).permissions, 'task', { subagent_type: requestedType }, options.workDir);
606
+ if (decision === 'deny') {
607
+ return {
608
+ error: `The sub-agent "${requestedType}" is disabled by a permission rule in this project ` +
609
+ `(permissions.deny in settings.json). This is a deliberate policy choice, not a missing file — ` +
610
+ 'do not create it and do not retry. Do the work yourself, or use a different sub-agent.',
611
+ };
612
+ }
613
+ }
545
614
  let agent = (0, agentTypes_1.findAgentType)(agentTypes, requestedType);
546
615
  let knownTypes = agentTypes;
547
616
  if (requestedType && !agent) {
@@ -584,9 +653,16 @@ async function runSubTask(input, options, agentTypes) {
584
653
  }
585
654
  return options.requestPermission(req);
586
655
  };
587
- const subMessages = [
588
- { role: 'user', content: [{ type: 'text', text: prompt }] },
589
- ];
656
+ // ── Resume: continue a previous sub-agent instead of starting cold ──────────
657
+ //
658
+ // The new prompt is appended as another user turn to the stored transcript, so
659
+ // the agent keeps every file it read and every conclusion it reached. Without
660
+ // this, "now also check the auth path" means re-describing the entire job and
661
+ // re-reading everything — the most common and most expensive kind of waste in
662
+ // a delegated workflow.
663
+ const subMessages = resumed
664
+ ? [...resumed.messages, { role: 'user', content: [{ type: 'text', text: prompt }] }]
665
+ : [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
590
666
  // A dedicated abort signal for this sub-agent, distinct from the parent's own
591
667
  // options.abortSignal (user hit Ctrl+C). Set to true either when the parent
592
668
  // aborts OR when the stall timeout below fires, whichever happens first —
@@ -609,6 +685,9 @@ async function runSubTask(input, options, agentTypes) {
609
685
  _agentScope: `sub_${++_subTaskCounter}`, // isolated todo store per sub-agent
610
686
  editorContext: null, // fresh isolated context for sub-agent
611
687
  model: agent?.model ?? options.model,
688
+ // Plan mode is inherited, never relaxed. If the main agent could spawn a
689
+ // sub-agent that writes, the lock would be one `task` call from useless.
690
+ planMode: options.planMode,
612
691
  nexrallMd: subNexrallMd,
613
692
  abortSignal: subAbort,
614
693
  requestPermission: gatedPermission,
@@ -671,7 +750,23 @@ async function runSubTask(input, options, agentTypes) {
671
750
  }
672
751
  // Normal completion: the final assistant message is the sub-agent's answer.
673
752
  const text = capSubTaskText(extractSubTaskText(result, true));
674
- return { output: text || '(sub-task completed with no text output)' };
753
+ // Store the transcript so a follow-up can continue this agent rather than
754
+ // re-running it from scratch, and tell the parent the id.
755
+ //
756
+ // Only on NORMAL completion. A timed-out or failed run is deliberately not
757
+ // resumable: its transcript ends mid-thought, often mid-tool-call, and
758
+ // resuming from that state invites the model to build on work whose status
759
+ // it cannot determine. Those paths already salvage their partial output as
760
+ // TEXT, which is the safe way to carry that information forward.
761
+ const agentId = resumed
762
+ ? ((0, agentRegistry_1.updateAgent)(resumed.id, result), resumed.id)
763
+ : (0, agentRegistry_1.rememberAgent)(agent?.name ?? null, (typeof input.description === 'string' && input.description.trim()) || prompt.slice(0, 80), result);
764
+ const body = text || '(sub-task completed with no text output)';
765
+ return {
766
+ output: `${body}\n\n[resumable: this sub-agent is "${agentId}". To ask IT a follow-up — keeping ` +
767
+ 'everything it already read and concluded — call task again with resume_agent_id="' + agentId +
768
+ '" instead of writing a new prompt from scratch.]',
769
+ };
675
770
  }
676
771
  catch (err) {
677
772
  // Same salvage rule as the timeout path above, for the other way a sub-agent
@@ -760,6 +855,26 @@ function compactionThresholds() {
760
855
  // so we require at least this many bytes reclaimed before accepting a prune.
761
856
  const PRUNE_MIN_RECLAIM_BYTES = 256 * 1024; // 256 KB
762
857
  const COMPACT_KEEP_MIN = 6; // always keep at least the last N messages verbatim
858
+ /**
859
+ * Bytes a compaction must reclaim to count as productive.
860
+ *
861
+ * Deliberately much smaller than PRUNE_MIN_RECLAIM_BYTES: a prune declines when the
862
+ * gain isn't worth busting the prompt cache, whereas by the time we are summarising
863
+ * we are already committed to rewriting the prefix — the only question is whether the
864
+ * summariser is making ANY headway. 32 KB is small enough that a genuinely useful
865
+ * compaction always clears it, large enough that shuffling a few bytes doesn't.
866
+ */
867
+ const COMPACT_MIN_RECLAIM_BYTES = 32 * 1024; // 32 KB
868
+ /**
869
+ * Consecutive non-productive compaction attempts before auto-compaction is switched
870
+ * off for the rest of the run.
871
+ *
872
+ * 3 rather than 1 because the failure is often transient — a summariser stream that
873
+ * blipped will usually succeed on the next turn, and giving up instantly would lose
874
+ * the safety net for a whole long session over one network hiccup. 3 also bounds the
875
+ * wasted spend: at most three summariser calls, not hundreds.
876
+ */
877
+ const COMPACT_MAX_FAILURES = 3;
763
878
  // Byte-level safety net, independent of the token estimate.
764
879
  //
765
880
  // Tool-heavy sessions on large codebases accumulate many tool_result blocks
@@ -943,7 +1058,7 @@ function transcriptOf(messages) {
943
1058
  const LEDGER_MAX_FILES = 60; // cap the file list so the preamble can't balloon
944
1059
  const LEDGER_MAX_NOTES = 20; // cap verification/among notes
945
1060
  function createLedger() {
946
- return { filesTouched: new Map(), verifications: [], testIntegrity: [], epoch: 0 };
1061
+ return { filesTouched: new Map(), filesTouchedTotal: 0, verifications: [], testIntegrity: [], testIntegrityTotal: 0, epoch: 0 };
947
1062
  }
948
1063
  /** Record one tool call's effect on the ledger (deterministic, no model call). */
949
1064
  function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
@@ -956,7 +1071,32 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
956
1071
  const p = typeof input?.path === 'string' ? input.path : undefined;
957
1072
  if (p) {
958
1073
  const prev = ledger.filesTouched.get(p);
1074
+ if (!prev)
1075
+ ledger.filesTouchedTotal++;
1076
+ // DELETE before SET, so a re-touched path moves to the BACK of the insertion
1077
+ // order. `Map.set` on an existing key keeps its ORIGINAL slot, which quietly
1078
+ // broke the eviction policy below: a file edited hundreds of times over a long
1079
+ // session kept the position of its FIRST edit, so it aged out like a file nobody
1080
+ // had looked at since — and on the next edit it was re-inserted as "new", double-
1081
+ // counting filesTouchedTotal (which is documented as DISTINCT paths). Making the
1082
+ // Map a true LRU-by-touch is what lets the `key !== p` guard below mean anything.
1083
+ ledger.filesTouched.delete(p);
959
1084
  ledger.filesTouched.set(p, { tool: toolName, edits: (prev?.edits ?? 0) + 1 });
1085
+ // Bound the Map itself, not just its rendering. LEDGER_MAX_FILES caps how many
1086
+ // paths the preamble PRINTS (see ledgerSummary's slice), but the Map was only ever
1087
+ // written to — so a multi-hour run touching thousands of files grew it without
1088
+ // limit, and it is deliberately retained across every compaction. Evict the
1089
+ // least-recently-touched entries once we hold well beyond what can ever be
1090
+ // displayed. Hysteresis (evict down to 2× only once we exceed 4×) keeps this an
1091
+ // occasional bulk sweep instead of a delete on every single write.
1092
+ if (ledger.filesTouched.size > LEDGER_MAX_FILES * 4) {
1093
+ for (const key of ledger.filesTouched.keys()) {
1094
+ if (ledger.filesTouched.size <= LEDGER_MAX_FILES * 2)
1095
+ break;
1096
+ if (key !== p)
1097
+ ledger.filesTouched.delete(key);
1098
+ }
1099
+ }
960
1100
  }
961
1101
  // Reward-hacking guard: if this write WEAKENED a test file, record it so the
962
1102
  // signal survives compaction and can be surfaced before the agent finishes.
@@ -976,6 +1116,7 @@ function ledgerRecord(ledger, toolName, input, ok, output, exitCode) {
976
1116
  if (reasons.length && p) {
977
1117
  for (const reason of reasons) {
978
1118
  ledger.testIntegrity.push({ path: p, reason });
1119
+ ledger.testIntegrityTotal++;
979
1120
  }
980
1121
  if (ledger.testIntegrity.length > LEDGER_MAX_NOTES * 2) {
981
1122
  ledger.testIntegrity.splice(0, ledger.testIntegrity.length - LEDGER_MAX_NOTES);
@@ -1008,8 +1149,11 @@ function ledgerSummary(ledger) {
1008
1149
  const lines = [];
1009
1150
  if (ledger.filesTouched.size) {
1010
1151
  const files = [...ledger.filesTouched.entries()];
1011
- const shown = files.slice(0, LEDGER_MAX_FILES);
1012
- lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouched.size}):`);
1152
+ // The TAIL, not the head: the Map is ordered least-recently-touched first, so
1153
+ // slicing from the front showed the OLDEST files and reliably omitted the ones the
1154
+ // agent was working on right now — the opposite of what this preamble is for.
1155
+ const shown = files.slice(-LEDGER_MAX_FILES);
1156
+ lines.push(`FILES CHANGED THIS SESSION (${ledger.filesTouchedTotal || ledger.filesTouched.size}):`);
1013
1157
  for (const [p, meta] of shown) {
1014
1158
  lines.push(` • ${p} (${meta.tool}${meta.edits > 1 ? ` ×${meta.edits}` : ''})`);
1015
1159
  }
@@ -1342,17 +1486,36 @@ async function runAgentLoop(initialMessages, options) {
1342
1486
  const hooks = loadHooks(options.workDir);
1343
1487
  const depth = options._depth ?? 0;
1344
1488
  const agentScope = options._agentScope ?? 'root';
1489
+ // Settings are read before the agent catalogue because a `deny` rule can
1490
+ // switch a sub-agent off, and an agent that may not run must not be
1491
+ // advertised (see below).
1492
+ const settings = (0, rules_1.loadSettings)(options.workDir);
1345
1493
  // Discover custom sub-agent types. Only the top-level agent is told the
1346
1494
  // catalogue (sub-agents can't spawn further), but every level resolves types.
1347
- const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir);
1495
+ //
1496
+ // Denied agents are filtered OUT of the catalogue rather than left in it to be
1497
+ // refused on dispatch. Listing an agent you have forbidden trains the model to
1498
+ // spend a tool call discovering the refusal, and "available types: …" on the
1499
+ // error path would name it again. Enforcement still happens at dispatch
1500
+ // (runSubTask) — this is the cosmetic half; that is the load-bearing half.
1501
+ const agentTypes = (0, agentTypes_1.loadAgentTypes)(options.workDir).filter((t) => (0, rules_1.evaluatePermission)(settings.permissions, 'task', { subagent_type: t.name }, options.workDir) !== 'deny');
1348
1502
  const agentsCatalogue = depth === 0 ? (0, agentTypes_1.summariseAgents)(agentTypes) : '';
1349
1503
  // Skills catalogue — unlike agentsCatalogue, available at every depth: a skill is
1350
1504
  // just a reusable prompt template (via use_skill), not another spawn point, so
1351
1505
  // sub-agents benefit from the same playbooks without the recursion concerns that
1352
1506
  // gate agentsCatalogue to the top level.
1353
1507
  const skillsCatalogue = (0, skills_1.summariseSkills)((0, skills_1.loadSkills)(options.workDir));
1508
+ // Plan mode instructions ride the project-instructions channel, which is
1509
+ // authoritative in the system prompt. Prepended rather than appended: the
1510
+ // lock has to be read before the project conventions it overrides.
1511
+ //
1512
+ // Telling the model is not the enforcement (checkPlanMode at the call site is)
1513
+ // — it exists so the model spends the turn planning instead of discovering the
1514
+ // lock one refused tool call at a time.
1515
+ const planAwareNexrallMd = options.planMode
1516
+ ? planMode_1.PLAN_MODE_INSTRUCTIONS + (options.nexrallMd ? `\n\n---\n\n${options.nexrallMd}` : '')
1517
+ : options.nexrallMd;
1354
1518
  // Optional OS-level bash sandbox (opt-in via settings.json "sandbox").
1355
- const settings = (0, rules_1.loadSettings)(options.workDir);
1356
1519
  const sandboxCfg = (0, sandbox_1.parseSandboxConfig)(settings.raw.sandbox) ?? undefined;
1357
1520
  // Soft iteration budget + optional auto-continue past it (see resolvers above).
1358
1521
  const maxIterations = resolveMaxIterations(options.maxIterations, settings.raw);
@@ -1383,6 +1546,12 @@ async function runAgentLoop(initialMessages, options) {
1383
1546
  let completedRounds = 0;
1384
1547
  let stalledOut = false; // tripped the runaway guard (all-failed rounds)
1385
1548
  let consecutiveErrorRounds = 0; // rounds where every tool call errored
1549
+ // Repeated-identical-failure guard: see the runaway guards below. Tracked
1550
+ // separately from consecutiveErrorRounds because a round can contain a succeeding
1551
+ // call and still be part of a livelock.
1552
+ let repeatedErrorRounds = 0;
1553
+ let lastErrorSignature = '';
1554
+ let stalledRepeatError = null;
1386
1555
  let budget = maxIterations; // extended by auto-continue, capped at hardCap
1387
1556
  let iteration = 0;
1388
1557
  // ─── Verification nudge (GAP D) ───────────────────────────────────────────────
@@ -1409,6 +1578,31 @@ async function runAgentLoop(initialMessages, options) {
1409
1578
  let claimEvidenceNudged = false;
1410
1579
  // GAP E — deterministic progress ledger, preserved verbatim across compactions.
1411
1580
  const ledger = createLedger();
1581
+ // ─── Auto-compact circuit breaker ─────────────────────────────────────────────
1582
+ //
1583
+ // The in-loop compaction trigger below re-derives its pressure from the CURRENT
1584
+ // body on every iteration. That is correct, but it means a compaction which does
1585
+ // not shrink anything leaves the trigger condition still true — so the next
1586
+ // iteration pays for another summariser call over the same (up to ~600KB)
1587
+ // transcript, and so on for the rest of the run. Two ways that happens:
1588
+ //
1589
+ // • autoCompactMessages returns false (summariser stream threw, or came back
1590
+ // empty). Nothing was replaced, so the pressure is unchanged.
1591
+ // • It returns true but cannot get under MAX_BODY_BYTES, because the messages
1592
+ // it must retain (COMPACT_KEEP_MIN) are themselves huge. `lastPromptTokens = 0`
1593
+ // suppresses only the TOKEN trigger; the BYTE trigger fires again immediately.
1594
+ //
1595
+ // Both are invisible to the user (the success notice only prints when `did`), so
1596
+ // the symptom is a long run that silently gets slower and more expensive. Count
1597
+ // consecutive non-productive attempts and stop trying after a few — losing
1598
+ // compaction degrades gracefully (the turn may still fit, and the prune pass
1599
+ // still runs), whereas an unbounded retry loop does not.
1600
+ //
1601
+ // The resume-time compactor already had exactly these guards
1602
+ // (compactMessagesForResume: a bounded loop plus `if (!did) break`); this brings
1603
+ // the in-loop path in line with it.
1604
+ let compactFailures = 0;
1605
+ let compactDisabled = false;
1412
1606
  try {
1413
1607
  for (; iteration < budget; iteration++) {
1414
1608
  if (options.abortSignal?.aborted)
@@ -1457,10 +1651,17 @@ async function runAgentLoop(initialMessages, options) {
1457
1651
  (options.onNotice ?? options.onText)(`\u267b\ufe0f Trimmed ~${(reclaimed / (1024 * 1024)).toFixed(1)}MB of already-processed tool output to keep this chat cheap to continue.`);
1458
1652
  }
1459
1653
  }
1460
- if (autoCompact && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1654
+ if (autoCompact && !compactDisabled && !compacting && (tokenPressure || bytePressure) && messages.length > COMPACT_KEEP_MIN + 2) {
1461
1655
  compacting = true;
1462
1656
  try {
1657
+ // Measured BEFORE, so "did it actually help?" is a fact about bytes rather
1658
+ // than a claim from the compactor. A compaction that returns true but
1659
+ // reclaims nothing is a failure for our purposes — it leaves the trigger
1660
+ // armed for the next iteration, which is precisely the runaway.
1661
+ const bytesBefore = estimateBodyBytes(messages);
1463
1662
  const did = await autoCompactMessages(messages, options, ledger);
1663
+ const bytesAfter = did ? estimateBodyBytes(messages) : bytesBefore;
1664
+ const reclaimed = bytesBefore - bytesAfter;
1464
1665
  if (did) {
1465
1666
  lastPromptTokens = 0; // stale — next usage event refreshes it
1466
1667
  const reason = bytePressure
@@ -1469,6 +1670,24 @@ async function runAgentLoop(initialMessages, options) {
1469
1670
  // Same reasoning as above: this is a system notice about housekeeping,
1470
1671
  // not part of the model's answer — keep it out of the text bubble.
1471
1672
  (options.onNotice ?? options.onText)(`\u267b\ufe0f Auto-compacted earlier conversation to stay within the ${reason}.`);
1673
+ // Refresh local pressure so the rest of THIS iteration sees the new size.
1674
+ bodyBytes = bytesAfter;
1675
+ bytePressure = bodyBytes > MAX_BODY_BYTES;
1676
+ }
1677
+ // Productive == it shrank the body meaningfully. A successful-but-useless
1678
+ // compaction counts as a failure, otherwise the "cannot get under the byte
1679
+ // cap" case would never trip the breaker.
1680
+ if (did && reclaimed >= COMPACT_MIN_RECLAIM_BYTES) {
1681
+ compactFailures = 0;
1682
+ }
1683
+ else if (++compactFailures >= COMPACT_MAX_FAILURES) {
1684
+ compactDisabled = true;
1685
+ // Surfaced ONCE. The user needs to know the automatic safety net is off
1686
+ // (so a context-window error later isn't a total surprise) and what to do
1687
+ // about it, but repeating this every iteration would be its own spam.
1688
+ (options.onNotice ?? options.onText)(`\u26a0\ufe0f Auto-compaction isn't reducing this conversation any further, so it's been switched off ` +
1689
+ `for the rest of this run to avoid repeated summarising. If the context fills up, start a fresh ` +
1690
+ `chat or run /compact manually.`);
1472
1691
  }
1473
1692
  }
1474
1693
  finally {
@@ -1569,7 +1788,7 @@ async function runAgentLoop(initialMessages, options) {
1569
1788
  effort: options.effort,
1570
1789
  env: options.env,
1571
1790
  editorContext: options.editorContext,
1572
- nexrallMd: options.nexrallMd,
1791
+ nexrallMd: planAwareNexrallMd,
1573
1792
  clientType: options.clientType,
1574
1793
  abortSignal: options.abortSignal,
1575
1794
  extraTools: options.mcpManager?.getAnthropicTools(),
@@ -1683,9 +1902,19 @@ async function runAgentLoop(initialMessages, options) {
1683
1902
  // and require the agent to either justify each change (legit refactor) or
1684
1903
  // revert it and fix the real code. Deterministic — the signal comes from
1685
1904
  // diff structure, not model self-report, so it can't be gamed away.
1686
- if (ledger.testIntegrity.length > testIntegrityNudgedCount) {
1687
- const fresh = ledger.testIntegrity.slice(testIntegrityNudgedCount);
1688
- testIntegrityNudgedCount = ledger.testIntegrity.length;
1905
+ //
1906
+ // Compared against testIntegrityTotal, NOT testIntegrity.length: the array is a
1907
+ // bounded window that gets trimmed, so once a long session passed ~40 findings
1908
+ // its length stopped growing and could even fall BELOW the already-nudged count,
1909
+ // permanently wedging this condition false and disabling the guard for the rest
1910
+ // of the run. The total only ever increases.
1911
+ if (ledger.testIntegrityTotal > testIntegrityNudgedCount) {
1912
+ // How many are genuinely new, clamped to what the window still holds — the
1913
+ // trimmed-away ones are unrecoverable, and reporting the tail we DO have is
1914
+ // strictly better than reporting nothing.
1915
+ const newCount = Math.min(ledger.testIntegrityTotal - testIntegrityNudgedCount, ledger.testIntegrity.length);
1916
+ const fresh = ledger.testIntegrity.slice(ledger.testIntegrity.length - newCount);
1917
+ testIntegrityNudgedCount = ledger.testIntegrityTotal;
1689
1918
  const bullet = fresh.map((t) => ` • ${t.path}: ${t.reason}`).join('\n');
1690
1919
  messages.push({
1691
1920
  role: 'user',
@@ -1794,6 +2023,21 @@ async function runAgentLoop(initialMessages, options) {
1794
2023
  options.onToolResult(name, result);
1795
2024
  return { block: { ...block, id }, result };
1796
2025
  }
2026
+ // ── Plan mode: a session-wide read-only lock ────────────────────────
2027
+ //
2028
+ // Checked BEFORE requestPermission on purpose. Routing it through the
2029
+ // permission prompt would ask the user to approve something that is
2030
+ // not theirs to approve in that moment, and a model told "denied by
2031
+ // user" reliably re-asks. This refusal instead says plainly that no
2032
+ // answer here can unlock it.
2033
+ if (options.planMode) {
2034
+ const refusal = (0, planMode_1.checkPlanMode)(name, input);
2035
+ if (refusal) {
2036
+ result = { error: refusal.message };
2037
+ options.onToolResult(name, result);
2038
+ return { block: { ...block, id }, result };
2039
+ }
2040
+ }
1797
2041
  // Request permission
1798
2042
  const description = humanDescription(name, input);
1799
2043
  let permitted;
@@ -1969,16 +2213,46 @@ async function runAgentLoop(initialMessages, options) {
1969
2213
  // tool_result user turn). Let the caller checkpoint progress so a crash
1970
2214
  // mid-run loses only the in-flight step, not the whole session.
1971
2215
  options.onProgress?.(messages);
1972
- // Runaway guard: if every tool call in this round failed, count it. Enough
1973
- // consecutive all-failed rounds (e.g. a command that always errors, or the
1974
- // user denying every permission) means we're stuck stop instead of
1975
- // burning the whole budget spinning.
1976
- const allErrored = toolResults.length > 0 && toolResults.every(({ result }) => result.error !== undefined);
2216
+ // ── Runaway guards ────────────────────────────────────────────────────────
2217
+ //
2218
+ // TWO independent counters, because "stuck" has two shapes and the original
2219
+ // all-failed test only caught the first.
2220
+ //
2221
+ // 1. TOTAL failure: every call in the round errored (a command that always
2222
+ // errors, the user denying every permission). Unambiguous.
2223
+ //
2224
+ // 2. REPEATED failure: the SAME error keeps coming back, round after round,
2225
+ // even though other calls in those rounds succeed. This is the livelock the
2226
+ // `.every()` test missed entirely — one trivially-succeeding sibling (say a
2227
+ // `read_file` alongside an `edit_file` that fails identically every time)
2228
+ // reset the counter to 0 forever, so a genuine loop burned the full
2229
+ // 2000-iteration ceiling instead of stopping at 8. That is the expensive,
2230
+ // user-visible "it just spun for an hour" failure.
2231
+ //
2232
+ // Keyed on tool + error text so a DIFFERENT error each round (real progress
2233
+ // through a chain of distinct problems) does not trip it.
2234
+ const errored = toolResults.filter(({ result }) => result.error !== undefined);
2235
+ const allErrored = toolResults.length > 0 && errored.length === toolResults.length;
1977
2236
  consecutiveErrorRounds = allErrored ? consecutiveErrorRounds + 1 : 0;
1978
2237
  if (consecutiveErrorRounds >= STALL_LIMIT) {
1979
2238
  stalledOut = true;
1980
2239
  break;
1981
2240
  }
2241
+ // Signature of this round's failures, order-independent and truncated so a long
2242
+ // error body (or a path echoed inside it) doesn't make every occurrence unique.
2243
+ const errSignature = errorRoundSignature(errored.map(({ block, result }) => ({ name: block.name, error: String(result.error) })));
2244
+ if (errSignature && errSignature === lastErrorSignature) {
2245
+ repeatedErrorRounds++;
2246
+ }
2247
+ else {
2248
+ repeatedErrorRounds = 0;
2249
+ lastErrorSignature = errSignature;
2250
+ }
2251
+ if (repeatedErrorRounds >= REPEAT_STALL_LIMIT) {
2252
+ stalledOut = true;
2253
+ stalledRepeatError = errored[0] ? String(errored[0].result.error).slice(0, 300) : null;
2254
+ break;
2255
+ }
1982
2256
  // Auto-continue: about to exhaust the current budget but the model is still
1983
2257
  // calling tools (task unfinished) and we're under the ceiling → extend the
1984
2258
  // budget by another segment and keep going, so the agent finishes on its own
@@ -1994,12 +2268,19 @@ async function runAgentLoop(initialMessages, options) {
1994
2268
  // long run never just goes silent. History ends on a tool_result turn, so
1995
2269
  // "continue" resumes exactly where it left off.
1996
2270
  if (!options.abortSignal?.aborted && !completedCleanly) {
2271
+ // Routed through onNotice (falling back to onText) like every other housekeeping
2272
+ // message in this file — these are statements from the harness, not from the model,
2273
+ // and splicing them into the assistant's own bubble reads as if it said them.
1997
2274
  if (stalledOut) {
1998
- options.onText(`\n🛑 Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
1999
- `Fix the underlying error (or grant the needed permission) and send "continue".\n`);
2275
+ (options.onNotice ?? options.onText)(stalledRepeatError
2276
+ ? `\n🛑 Stopped: the same tool error repeated ${REPEAT_STALL_LIMIT} rounds in a row, so the agent ` +
2277
+ `was looping without making progress. The recurring error was:\n${stalledRepeatError}\n` +
2278
+ `Fix that underlying cause (or grant the needed permission) and send "continue".\n`
2279
+ : `\n🛑 Stopped: the last ${STALL_LIMIT} tool rounds all failed, so the agent looked stuck. ` +
2280
+ `Fix the underlying error (or grant the needed permission) and send "continue".\n`);
2000
2281
  }
2001
2282
  else if (iteration >= budget) {
2002
- options.onText(`\n⏸️ Stopped at the ${budget}-step safety limit — the task may be incomplete. ` +
2283
+ (options.onNotice ?? options.onText)(`\n⏸️ Stopped at the ${budget}-step safety limit — the task may be incomplete. ` +
2003
2284
  `Send "continue" to resume, or raise the limit via "maxIterations" in .nexrall/settings.json ` +
2004
2285
  `(or the NEXRALL_MAX_ITERATIONS env var). Auto-continue can be disabled with "autoContinue": false.\n`);
2005
2286
  }
@@ -0,0 +1,20 @@
1
+ export interface PlanModeRefusal {
2
+ /** Machine-readable reason, for tests and telemetry. */
3
+ reason: 'mutating-tool' | 'bash-not-read-only';
4
+ /** Message shown to the MODEL. Must stop it retrying or asking for approval. */
5
+ message: string;
6
+ }
7
+ /**
8
+ * Is this bash command provably read-only?
9
+ *
10
+ * "Provably" is doing real work: unknown verbs are refused, not guessed at.
11
+ */
12
+ export declare function isReadOnlyCommand(command: string): boolean;
13
+ /**
14
+ * Should this tool call be refused because the session is in plan mode?
15
+ * Returns null when the call is allowed.
16
+ */
17
+ export declare function checkPlanMode(tool: string, input: Record<string, unknown>): PlanModeRefusal | null;
18
+ /** Text appended to the system prompt while plan mode is active. */
19
+ export declare const PLAN_MODE_INSTRUCTIONS: string;
20
+ //# sourceMappingURL=planMode.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"planMode.d.ts","sourceRoot":"","sources":["../../src/agent/planMode.ts"],"names":[],"mappings":"AAwHA,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,MAAM,EAAE,eAAe,GAAG,oBAAoB,CAAC;IAC/C,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;CACjB;AAMD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAgE1D;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,eAAe,GAAG,IAAI,CAoCxB;AAED,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,QAqBvB,CAAC"}