codeep 3.3.3 → 3.4.1

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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -37,10 +37,12 @@ function calculateDynamicTimeout(iteration, baseTimeout) {
37
37
  return Math.max(calculatedTimeout, 120000);
38
38
  }
39
39
  import { parseToolCalls, executeTool, createActionLog } from './tools.js';
40
+ import { trustBearingWrite, forgetHooksDirectory, NO_CONFIRMER_REFUSAL } from './toolExecution.js';
40
41
  import { config } from '../config/index.js';
41
42
  import { supportsNativeTools } from '../config/providers.js';
43
+ import { isMcpToolName, isVirtualMcpToolName } from './mcpRegistry.js';
42
44
  import { startSession, endSession, undoLastAction, undoAllActions, getCurrentSession, getRecentSessions, formatSession } from './history.js';
43
- import { runAllVerifications, formatErrorsForAgent, hasVerificationErrors, getVerificationSummary } from './verify.js';
45
+ import { runAllVerifications, formatErrorsForAgent, hasVerificationErrors, getVerificationSummary, failedChecks, checksNotRun } from './verify.js';
44
46
  import { gatherSmartContext, formatSmartContext, extractTargetFile } from './smartContext.js';
45
47
  import { planTasks, formatTaskPlan } from './taskPlanner.js';
46
48
  import { getTaskContextPrompt } from './taskContext.js';
@@ -138,6 +140,85 @@ export function buildDangerousTools(extra = []) {
138
140
  tools.add(t);
139
141
  return tools;
140
142
  }
143
+ /**
144
+ * Whether a tool call must go through the permission prompt this run.
145
+ *
146
+ * MCP tools (`<server>__<tool>`) can do anything their server can — write
147
+ * files, run SQL, drive a browser — and their names never appear in the
148
+ * built-in set, so a mode that confirms dangerous operations confirms these
149
+ * too. The resource/prompt wrappers only read, and stay unprompted.
150
+ */
151
+ export function requiresPermission(tool, dangerousTools) {
152
+ return dangerousTools.has(tool) || (isMcpToolName(tool) && !isVirtualMcpToolName(tool));
153
+ }
154
+ /**
155
+ * Read a sub-agent's `model:` setting ("provider/model" or a bare model) as
156
+ * the runtime for its nested run. A known provider prefix switches provider;
157
+ * anything else is a model on the current provider. The protocol is kept when
158
+ * the provider stays the same and supports it, and is otherwise that
159
+ * provider's default.
160
+ */
161
+ export async function resolveDelegateModel(spec, current) {
162
+ const slash = spec.indexOf('/');
163
+ if (slash < 0)
164
+ return { ...current, model: spec };
165
+ const providerId = spec.slice(0, slash);
166
+ const model = spec.slice(slash + 1);
167
+ const { getProvider } = await import('../config/providers.js');
168
+ const provider = getProvider(providerId);
169
+ if (!provider)
170
+ return { ...current, model };
171
+ const protocol = providerId === current.providerId && provider.protocols[current.protocol]
172
+ ? current.protocol
173
+ : provider.defaultProtocol;
174
+ // getApiKey() only reads the in-memory cache, so warm it for a provider the
175
+ // parent run has not used. Reading a key changes no settings.
176
+ try {
177
+ const { getApiKey, loadApiKey } = await import('../config/index.js');
178
+ if (!getApiKey(providerId))
179
+ await loadApiKey(providerId);
180
+ }
181
+ catch { /* the request itself reports a missing key */ }
182
+ return { providerId, model, protocol };
183
+ }
184
+ /**
185
+ * The user-facing account of checks that still fail when verification stops.
186
+ * The command and the first few errors are enough to act on; the full output
187
+ * went to the model.
188
+ */
189
+ function describeVerificationFailure(results) {
190
+ const failed = failedChecks(results);
191
+ const lines = [`✗ Verification failed: ${failed.length}/${results.length} checks`];
192
+ for (const r of failed) {
193
+ lines.push(`- ${r.type}: \`${r.command}\``);
194
+ for (const e of r.errors.slice(0, 5)) {
195
+ const where = e.file ? `${e.file}${e.line ? `:${e.line}` : ''}: ` : '';
196
+ const message = e.message.length > 200 ? e.message.slice(0, 200) + '…' : e.message;
197
+ lines.push(` - ${where}${message}`);
198
+ }
199
+ if (r.errors.length > 5)
200
+ lines.push(` - …and ${r.errors.length - 5} more`);
201
+ }
202
+ return lines.join('\n');
203
+ }
204
+ /**
205
+ * The user-facing account of checks that could not be carried out, or '' when
206
+ * every check ran. They prove nothing either way, so the user is told the
207
+ * change was not verified by them rather than that it passed or failed.
208
+ */
209
+ function describeChecksNotRun(results) {
210
+ const notRun = checksNotRun(results);
211
+ if (notRun.length === 0)
212
+ return '';
213
+ const lines = [`⚠ Verification could not run: ${notRun.length}/${results.length} checks`];
214
+ for (const r of notRun)
215
+ lines.push(`- ${r.type}: \`${r.command}\` — ${r.notRun}`);
216
+ return lines.join('\n');
217
+ }
218
+ /** A result whose whole response is a notice runAgent wrote (see unstreamedText). */
219
+ function asNotice(result) {
220
+ return { ...result, unstreamedText: result.finalResponse.trim() };
221
+ }
141
222
  /**
142
223
  * Build the result for a run that paused at a safety limit. Pausing is a normal,
143
224
  * resumable state — not an error — so the summary tells the user how to resume.
@@ -216,7 +297,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
216
297
  // A structured custom bot is resolved once per run. This keeps a cloud sync
217
298
  // or file edit from changing policy halfway through an in-flight request.
218
299
  const activePersonality = opts.personalityOverride ?? getActivePersonality(projectContext.root);
219
- const currentRuntime = {
300
+ const currentRuntime = opts.modelOverride ?? {
220
301
  providerId: String(config.get('provider')),
221
302
  model: String(config.get('model')),
222
303
  protocol: config.get('protocol'),
@@ -466,6 +547,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
466
547
  messages.push({ role: 'user', content: initialPrompt });
467
548
  let iteration = 0;
468
549
  let finalResponse = '';
550
+ // What the loop added to finalResponse after the model's last reply (see
551
+ // AgentResult.unstreamedText). Reset whenever a reply replaces the response.
552
+ let appended = '';
553
+ const appendToResponse = (text) => {
554
+ finalResponse += text;
555
+ appended += text;
556
+ };
469
557
  // Initialised rather than merely declared: the `finally` reads it to decide
470
558
  // the audit outcome, and TypeScript is right that a throw before assignment
471
559
  // would leave it unset.
@@ -476,10 +564,18 @@ export async function runAgent(prompt, projectContext, options = {}) {
476
564
  // once or twice. More retries than that usually means the model is stuck, not
477
565
  // that it needs a third chance — bail out instead of spamming identical hints.
478
566
  const maxIncompleteWorkRetries = 2;
479
- // Track tools permanently allowed this session via allow_always
480
- const alwaysAllowedTools = new Set();
567
+ // Track tools permanently allowed this session via allow_always. A delegated
568
+ // sub-agent shares its parent's sets, so an answer holds across delegation.
569
+ const alwaysAllowedTools = opts.permissionMemory?.alwaysAllowed ?? new Set();
481
570
  // Track tools permanently rejected this session via reject_always
482
- const alwaysRejectedTools = new Set();
571
+ const alwaysRejectedTools = opts.permissionMemory?.alwaysRejected ?? new Set();
572
+ // Files that decide what runs later and were refused for good this session.
573
+ // Kept apart from the tool set on purpose: the TUI's only "no" button answers
574
+ // reject_always, so saying no to one `.git/config` prompt would otherwise
575
+ // turn off delete_file — and every other use of that tool — for the rest of
576
+ // the run. Keyed by the resolved path, so one answer covers every spelling
577
+ // of the same file.
578
+ const alwaysRejectedPaths = opts.permissionMemory?.alwaysRejectedPaths ?? new Set();
483
579
  // Tools that require permission when onRequestPermission is set (configurable)
484
580
  const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
485
581
  // Delegation handler: run a named (or generic) sub-agent in its own fresh
@@ -502,8 +598,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
502
598
  catch { /* fall back to a generic sub-agent */ }
503
599
  let roleAddendum = def?.prompt
504
600
  || 'You are a general-purpose sub-agent. Complete the task in your own context and return a concise, self-contained summary of what you did and the outcome.';
505
- if (def?.tools)
506
- roleAddendum += `\n\nYou may use ONLY these tools: ${def.tools.join(', ')}.`;
601
+ if (def?.tools) {
602
+ roleAddendum += def.tools.length
603
+ ? `\n\nYou may use ONLY these tools: ${def.tools.join(', ')}.`
604
+ : '\n\nYou may not use any tools.';
605
+ }
507
606
  if (def?.personality) {
508
607
  try {
509
608
  const { findPersonality } = await import('./personalities.js');
@@ -516,22 +615,15 @@ export async function runAgent(prompt, projectContext, options = {}) {
516
615
  const label = def?.name || 'agent';
517
616
  opts.onIteration?.(iteration, `⤷ delegating to ${label}…`);
518
617
  const tag = (text) => `⤷ ${label}: ${text}`;
519
- // Model override — swap config for the nested run, restore in finally.
520
- const prevModel = config.get('model');
521
- const prevProvider = config.get('provider');
522
- let swapped = false;
618
+ // Model override for the nested run only. It travels as an option, never
619
+ // through config: config is saved to disk and read by every session in
620
+ // this process, so swapping it there leaked the sub-agent's model into
621
+ // concurrent runs, reset the user's protocol on the way back, and stayed
622
+ // behind if the process died mid-delegation.
623
+ let modelOverride = opts.modelOverride;
523
624
  if (def?.model) {
524
625
  try {
525
- const m = String(def.model);
526
- if (m.includes('/')) {
527
- const { setProvider } = await import('../config/index.js');
528
- setProvider(m.slice(0, m.indexOf('/')));
529
- config.set('model', m.slice(m.indexOf('/') + 1));
530
- }
531
- else {
532
- config.set('model', m);
533
- }
534
- swapped = true;
626
+ modelOverride = await resolveDelegateModel(String(def.model), currentRuntime);
535
627
  }
536
628
  catch { /* keep parent's model */ }
537
629
  }
@@ -545,7 +637,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
545
637
  maxIterations: def?.maxIterations ?? Math.min(15, opts.maxIterations),
546
638
  maxDuration: opts.maxDuration,
547
639
  abortSignal: opts.abortSignal,
640
+ // The sub-agent works under the parent's rules: a dry run stays dry,
641
+ // tools gated for this run stay gated, and "always" answers carry over.
642
+ dryRun: opts.dryRun,
548
643
  onRequestPermission: opts.onRequestPermission,
644
+ extraDangerousTools: opts.extraDangerousTools,
645
+ permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools, alwaysRejectedPaths },
646
+ modelOverride,
549
647
  onExecuteCommand: opts.onExecuteCommand,
550
648
  fs: opts.fs,
551
649
  mcpSessionId: opts.mcpSessionId,
@@ -560,16 +658,186 @@ export async function runAgent(prompt, projectContext, options = {}) {
560
658
  catch (err) {
561
659
  return fail(`Sub-agent "${label}" failed: ${err.message}`);
562
660
  }
563
- finally {
564
- if (swapped) {
661
+ };
662
+ // One path from "the model asked for a tool" to "the tool ran", shared by
663
+ // the main loop and the verification fix loop, so a tool call cannot skip a
664
+ // gate by arriving through the other one. Returns the tool's result, plus
665
+ // `refusal` (the text for the model) when a gate stopped it. Either way the
666
+ // result has already been reported through onToolResult and logged.
667
+ const dispatchToolCall = async (toolCall) => {
668
+ const refuse = (error, refusal) => {
669
+ const result = {
670
+ success: false,
671
+ output: '',
672
+ error,
673
+ tool: toolCall.tool,
674
+ parameters: toolCall.parameters,
675
+ };
676
+ opts.onToolResult?.(result, toolCall);
677
+ actions.push(createActionLog(toolCall, result));
678
+ return { result, refusal };
679
+ };
680
+ // Structured custom-bot policy is a runtime security boundary, not a
681
+ // prompt suggestion. It runs before permission UI or external ACP
682
+ // terminal delegation, so disallowed commands cannot escape via a
683
+ // different execution surface.
684
+ if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
685
+ const allowed = activePersonality.declaredTools?.join(', ') || 'none';
686
+ const refused = refuse(`Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`, `Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
687
+ // The one event nothing recorded before. A boundary you cannot audit
688
+ // is a boundary you have to take on faith.
689
+ recordAuditEvent(auditRoot, {
690
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
691
+ target: describeAuditTarget(toolCall), outcome: 'refused',
692
+ detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
693
+ });
694
+ return refused;
695
+ }
696
+ // Tool scoping for delegated sub-agents: reject any tool outside the
697
+ // agent's allowlist up front — no permission prompt, no execution.
698
+ if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
699
+ return refuse(`Tool "${toolCall.tool}" is not available to this sub-agent.`, `Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
700
+ }
701
+ const denied = () => refuse(`User rejected permission for ${toolCall.tool}`, `Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
702
+ // Writing a file that decides what runs later is code execution on a
703
+ // delay, not an edit: git runs `core.fsmonitor` itself on the next
704
+ // `git status` the status line makes, a `.codeep/hooks/` script runs on
705
+ // the next tool call, an MCP entry spawns a process. A prompt injection
706
+ // that gets one of these written has walked around every other gate, so
707
+ // the write is confirmed in EVERY confirmation mode — not only the tiers
708
+ // that happen to list write_file — and an "always allow" answer given for
709
+ // the tool never covers it. With nobody to ask, it fails the way a write
710
+ // the editor refused fails: proceeding quietly is the one outcome that
711
+ // cannot be taken back.
712
+ const trustBearing = trustBearingWrite(toolCall, projectContext.root || process.cwd());
713
+ if (trustBearing) {
714
+ if (!opts.onRequestPermission) {
715
+ const refusal = refuse(`Refused ${toolCall.tool} on ${trustBearing.path}: ${trustBearing.reason} ${NO_CONFIRMER_REFUSAL}`, `Tool ${toolCall.tool} was refused on ${trustBearing.path}. ${trustBearing.reason} Nobody could be asked to confirm it. Do not try again — tell the user to edit that file themselves.`);
716
+ recordAuditEvent(auditRoot, {
717
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
718
+ target: describeAuditTarget(toolCall), outcome: 'refused',
719
+ detail: `${trustBearing.path} decides what runs later and no confirmation was possible`,
720
+ });
721
+ return refusal;
722
+ }
723
+ // An "always deny" already given: for the tool, when the user really
724
+ // chose that in an ordinary prompt, or for this file.
725
+ if (alwaysRejectedTools.has(toolCall.tool) || alwaysRejectedPaths.has(trustBearing.file))
726
+ return denied();
727
+ const decision = classifyPermissionOutcome(await opts.onRequestPermission(toolCall, trustBearing));
728
+ // Neither answer is remembered for the TOOL. "Always allow" is not
729
+ // remembered at all: it was an answer about THIS file, and the next
730
+ // `.git/config` write must be asked about again. "Always deny" is
731
+ // remembered against the file — the fail-closed half of the same rule.
732
+ // Against the tool it would be a trap: the TUI offers Allow, Always
733
+ // Allow and Deny, and that Deny answers reject_always, so refusing one
734
+ // `.git/config` prompt would silently disable delete_file for the rest
735
+ // of the run.
736
+ if (decision !== 'allow-once' && decision !== 'allow-always') {
737
+ if (decision === 'deny-always')
738
+ alwaysRejectedPaths.add(trustBearing.file);
739
+ return denied();
740
+ }
741
+ }
742
+ else if (opts.onRequestPermission && requiresPermission(toolCall.tool, dangerousTools) && !alwaysAllowedTools.has(toolCall.tool)) {
743
+ // Every other tool: the run's dangerous set decides, and only when
744
+ // there is a callback to ask through (e.g. ACP/Zed).
745
+ // Skip without asking if permanently rejected this session
746
+ if (alwaysRejectedTools.has(toolCall.tool))
747
+ return denied();
748
+ // `null` and not nothing: this branch runs only when the call writes no
749
+ // such file, and saying so spares the dialog the second lookup.
750
+ const outcome = await opts.onRequestPermission(toolCall, null);
751
+ // Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
752
+ // any malformed/unknown outcome deny (see classifyPermissionOutcome).
753
+ const decision = classifyPermissionOutcome(outcome);
754
+ if (decision === 'allow-always') {
755
+ alwaysAllowedTools.add(toolCall.tool);
756
+ }
757
+ else if (decision !== 'allow-once') {
758
+ if (decision === 'deny-always')
759
+ alwaysRejectedTools.add(toolCall.tool);
760
+ return denied();
761
+ }
762
+ }
763
+ let toolResult;
764
+ // A dry run simulates every tool, delegation included — a sub-agent
765
+ // started from here would otherwise do the real work.
766
+ if (opts.dryRun) {
767
+ toolResult = {
768
+ success: true,
769
+ output: `[DRY RUN] Would execute: ${toolCall.tool}`,
770
+ tool: toolCall.tool,
771
+ parameters: toolCall.parameters,
772
+ };
773
+ }
774
+ else if (toolCall.tool === 'delegate') {
775
+ toolResult = await runDelegate(toolCall);
776
+ }
777
+ else if (opts.onExecuteCommand && toolCall.tool === 'execute_command') {
778
+ // Delegate to external terminal (e.g. Zed ACP terminal)
779
+ // Note: onExecuteCommand runs after the permission gate above
780
+ const command = toolCall.parameters.command;
781
+ const args = toolCall.parameters.args || [];
782
+ const cwd = projectContext.root || process.cwd();
783
+ if (!command) {
784
+ toolResult = {
785
+ success: false,
786
+ output: '',
787
+ error: 'execute_command called with missing command field',
788
+ tool: toolCall.tool,
789
+ parameters: toolCall.parameters,
790
+ };
791
+ }
792
+ else {
565
793
  try {
566
- const { setProvider } = await import('../config/index.js');
567
- setProvider(String(prevProvider));
568
- config.set('model', prevModel);
794
+ // Runs in the editor's terminal instead of ours, so executeTool's
795
+ // own invalidation never fires — but `git config core.hooksPath
796
+ // .evil` moves this repository's hooks just the same. Drop the
797
+ // cached answer here too, or the next write to the new hook
798
+ // directory goes through unasked.
799
+ forgetHooksDirectory();
800
+ const commandResult = await opts.onExecuteCommand(command, args, cwd);
801
+ toolResult = {
802
+ success: commandResult.exitCode === 0,
803
+ output: commandResult.stdout || '(no output)',
804
+ error: commandResult.exitCode !== 0 ? (commandResult.stderr || `exited with code ${commandResult.exitCode}`) : undefined,
805
+ tool: toolCall.tool,
806
+ parameters: toolCall.parameters,
807
+ };
808
+ }
809
+ catch (err) {
810
+ // The callback decides where the command runs and whether it can
811
+ // fall back to running here. When it throws, the command may already
812
+ // have run in the editor, so running it again locally could run it
813
+ // twice. Report the failure instead.
814
+ debug('onExecuteCommand callback threw:', err);
815
+ toolResult = {
816
+ success: false,
817
+ output: '',
818
+ error: `Command could not be run: ${err?.message ?? String(err)}`,
819
+ tool: toolCall.tool,
820
+ parameters: toolCall.parameters,
821
+ };
569
822
  }
570
- catch { /* ignore restore failure */ }
571
823
  }
572
824
  }
825
+ else {
826
+ toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId, opts.abortSignal);
827
+ }
828
+ opts.onToolResult?.(toolResult, toolCall);
829
+ // Log action
830
+ const actionLog = createActionLog(toolCall, toolResult);
831
+ actions.push(actionLog);
832
+ // createActionLog already classified this; reuse its verdict rather than
833
+ // re-deriving the action type in a second place that could drift.
834
+ recordAuditEvent(auditRoot, {
835
+ ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
836
+ target: describeAuditTarget(toolCall),
837
+ outcome: toolResult.success ? 'ok' : 'error',
838
+ detail: toolResult.success ? undefined : toolResult.error,
839
+ });
840
+ return { result: toolResult };
573
841
  };
574
842
  const maxTimeoutRetries = 3;
575
843
  const maxConsecutiveTimeouts = 30; // Allow more consecutive timeouts before giving up
@@ -588,7 +856,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
588
856
  // Check timeout
589
857
  if (Date.now() - startTime > opts.maxDuration) {
590
858
  const durationMin = Math.round(opts.maxDuration / 60000);
591
- result = buildPausedResult('time_limit', { iterations: iteration, actions, durationMin });
859
+ result = asNotice(buildPausedResult('time_limit', { iterations: iteration, actions, durationMin }));
592
860
  if (!opts.nested)
593
861
  writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
594
862
  return result;
@@ -596,13 +864,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
596
864
  // Check abort signal
597
865
  if (opts.abortSignal?.aborted) {
598
866
  debug('Agent aborted at iteration', iteration);
599
- result = {
867
+ result = asNotice({
600
868
  success: false,
601
869
  iterations: iteration,
602
870
  actions,
603
871
  finalResponse: 'Agent was stopped by user',
604
872
  aborted: true,
605
- };
873
+ });
606
874
  return result;
607
875
  }
608
876
  iteration++;
@@ -670,13 +938,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
670
938
  const err = error;
671
939
  // Handle user abort (not timeout)
672
940
  if (err.name === 'AbortError') {
673
- result = {
941
+ result = asNotice({
674
942
  success: false,
675
943
  iterations: iteration,
676
944
  actions,
677
945
  finalResponse: 'Agent was stopped by user',
678
946
  aborted: true,
679
- };
947
+ });
680
948
  return result;
681
949
  }
682
950
  // Handle timeout with retry
@@ -689,13 +957,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
689
957
  // Too many retries for this iteration
690
958
  if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
691
959
  // Too many consecutive timeouts overall, give up
692
- result = {
960
+ result = asNotice({
693
961
  success: false,
694
962
  iterations: iteration,
695
963
  actions,
696
964
  finalResponse: 'Agent stopped due to repeated API timeouts',
697
965
  error: `API timed out ${consecutiveTimeouts} times consecutively. Try increasing the timeout in settings or simplifying the task.`,
698
- };
966
+ });
699
967
  return result;
700
968
  }
701
969
  // Skip this iteration and try next
@@ -715,13 +983,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
715
983
  }
716
984
  // Don't retry on 4xx client errors except 429 (rate limit)
717
985
  if (err instanceof ApiError && err.status >= 400 && err.status < 500 && err.status !== 429) {
718
- result = {
986
+ result = asNotice({
719
987
  success: false,
720
988
  iterations: iteration,
721
989
  actions,
722
990
  finalResponse: '',
723
991
  error: err.message,
724
- };
992
+ });
725
993
  return result;
726
994
  }
727
995
  // All non-abort errors are retryable — retry with backoff
@@ -739,7 +1007,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
739
1007
  // Rate limit exhausted — stop immediately, no point hammering a throttled API
740
1008
  consecutiveRateLimits++;
741
1009
  if (consecutiveRateLimits >= maxConsecutiveRateLimits) {
742
- result = {
1010
+ result = asNotice({
743
1011
  success: false,
744
1012
  iterations: iteration,
745
1013
  actions,
@@ -747,7 +1015,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
747
1015
  ? `Agent paused after ${actions.length} action(s) — API rate limit reached. Wait a moment and try again.`
748
1016
  : 'API rate limit reached. Wait a moment and run the agent again.',
749
1017
  error: `Rate limited (429) after ${maxTimeoutRetries} retries: ${err.message}`,
750
- };
1018
+ });
751
1019
  return result;
752
1020
  }
753
1021
  }
@@ -757,7 +1025,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
757
1025
  // Don't throw — skip this iteration like timeouts do
758
1026
  consecutiveTimeouts++;
759
1027
  if (consecutiveTimeouts >= maxConsecutiveTimeouts) {
760
- result = {
1028
+ result = asNotice({
761
1029
  success: false,
762
1030
  iterations: iteration,
763
1031
  actions,
@@ -765,7 +1033,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
765
1033
  ? `Agent made progress (${actions.length} actions) but API errors prevented completion. You can continue by running the agent again.`
766
1034
  : 'Agent could not complete the task due to repeated API errors. Check your API key and network connection.',
767
1035
  error: `API failed after ${maxTimeoutRetries} retries: ${err.message}`,
768
- };
1036
+ });
769
1037
  return result;
770
1038
  }
771
1039
  messages.push({
@@ -792,7 +1060,8 @@ export async function runAgent(prompt, projectContext, options = {}) {
792
1060
  const lastUsage = getLastUsage();
793
1061
  const inputTokens = lastUsage?.promptTokens ?? 0;
794
1062
  if (inputTokens > 0) {
795
- const contextWindow = getModelContextWindow(config.get('model'));
1063
+ // The model this run talks to, which a sub-agent may have overridden.
1064
+ const contextWindow = getModelContextWindow(String(chatRuntime.model ?? config.get('model')));
796
1065
  const pct = Math.round(inputTokens / contextWindow * 100);
797
1066
  const threshold = pct >= 95 ? 95 : pct >= 80 ? 80 : 0;
798
1067
  if (threshold > 0 && threshold > lastBudgetWarning) {
@@ -815,7 +1084,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
815
1084
  }
816
1085
  // Warn the user if Ollama model fails to produce tool calls early on
817
1086
  if (toolCalls.length === 0 && iteration <= 2 && providerId === 'ollama') {
818
- const model = config.get('model');
1087
+ const model = String(chatRuntime.model ?? config.get('model'));
819
1088
  const paramMatch = model.toLowerCase().match(/(\d+(?:\.\d+)?)b/);
820
1089
  const params = paramMatch ? parseFloat(paramMatch[1]) : null;
821
1090
  if (params !== null && params < 7) {
@@ -827,6 +1096,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
827
1096
  debug(`No tool calls at iteration ${iteration}, content length: ${content.length}`);
828
1097
  // Remove <think>...</think> tags from response (some models include thinking)
829
1098
  // Also remove Tool parameters/tool call artifacts that AI sometimes includes in text
1099
+ appended = '';
830
1100
  finalResponse = content
831
1101
  .replace(/<think>[\s\S]*?<\/think>/gi, '')
832
1102
  .replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
@@ -855,6 +1125,9 @@ export async function runAgent(prompt, projectContext, options = {}) {
855
1125
  role: 'user',
856
1126
  content: 'Continue. Execute the tool calls now.'
857
1127
  });
1128
+ // The fragment is not an answer. Left in place, it would make a run
1129
+ // that later hits the step limit look finished.
1130
+ finalResponse = '';
858
1131
  continue;
859
1132
  }
860
1133
  // Reset counter once model produces real output or we give up
@@ -868,144 +1141,16 @@ export async function runAgent(prompt, projectContext, options = {}) {
868
1141
  // Execute tool calls
869
1142
  const toolResults = [];
870
1143
  for (const toolCall of toolCalls) {
1144
+ // Stop kills a running command at once; the calls queued behind it in
1145
+ // the same reply must not go ahead and write files after that.
1146
+ if (opts.abortSignal?.aborted)
1147
+ break;
871
1148
  opts.onToolCall?.(toolCall);
872
- // Structured custom-bot policy is a runtime security boundary, not a
873
- // prompt suggestion. It runs before permission UI or external ACP
874
- // terminal delegation, so disallowed commands cannot escape via a
875
- // different execution surface.
876
- if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
877
- const allowed = activePersonality.declaredTools?.join(', ') || 'none';
878
- const denied = {
879
- success: false,
880
- output: '',
881
- error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
882
- tool: toolCall.tool,
883
- parameters: toolCall.parameters,
884
- };
885
- opts.onToolResult?.(denied, toolCall);
886
- actions.push(createActionLog(toolCall, denied));
887
- // The one event nothing recorded before. A boundary you cannot audit
888
- // is a boundary you have to take on faith.
889
- recordAuditEvent(auditRoot, {
890
- ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
891
- target: describeAuditTarget(toolCall), outcome: 'refused',
892
- detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
893
- });
894
- toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
1149
+ const { result: toolResult, refusal } = await dispatchToolCall(toolCall);
1150
+ if (refusal) {
1151
+ toolResults.push(refusal);
895
1152
  continue;
896
1153
  }
897
- // Tool scoping for delegated sub-agents: reject any tool outside the
898
- // agent's allowlist up front — no permission prompt, no execution.
899
- if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
900
- const denied = {
901
- success: false,
902
- output: '',
903
- error: `Tool "${toolCall.tool}" is not available to this sub-agent.`,
904
- tool: toolCall.tool,
905
- parameters: toolCall.parameters,
906
- };
907
- opts.onToolResult?.(denied, toolCall);
908
- actions.push(createActionLog(toolCall, denied));
909
- toolResults.push(`Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
910
- continue;
911
- }
912
- // Permission check for dangerous tools (only when callback is provided, e.g. ACP/Zed)
913
- if (opts.onRequestPermission && dangerousTools.has(toolCall.tool) && !alwaysAllowedTools.has(toolCall.tool)) {
914
- const rejectResult = () => {
915
- const toolResult = {
916
- success: false,
917
- output: '',
918
- error: `User rejected permission for ${toolCall.tool}`,
919
- tool: toolCall.tool,
920
- parameters: toolCall.parameters,
921
- };
922
- opts.onToolResult?.(toolResult, toolCall);
923
- actions.push(createActionLog(toolCall, toolResult));
924
- toolResults.push(`Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
925
- return toolResult;
926
- };
927
- // Skip without asking if permanently rejected this session
928
- if (alwaysRejectedTools.has(toolCall.tool)) {
929
- rejectResult();
930
- continue;
931
- }
932
- const outcome = await opts.onRequestPermission(toolCall);
933
- // Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
934
- // any malformed/unknown outcome deny (see classifyPermissionOutcome).
935
- const decision = classifyPermissionOutcome(outcome);
936
- if (decision === 'allow-always') {
937
- alwaysAllowedTools.add(toolCall.tool);
938
- }
939
- else if (decision === 'allow-once') {
940
- // proceed this once
941
- }
942
- else {
943
- if (decision === 'deny-always')
944
- alwaysRejectedTools.add(toolCall.tool);
945
- rejectResult();
946
- continue;
947
- }
948
- }
949
- let toolResult;
950
- if (toolCall.tool === 'delegate') {
951
- toolResult = await runDelegate(toolCall);
952
- }
953
- else if (opts.dryRun) {
954
- toolResult = {
955
- success: true,
956
- output: `[DRY RUN] Would execute: ${toolCall.tool}`,
957
- tool: toolCall.tool,
958
- parameters: toolCall.parameters,
959
- };
960
- }
961
- else if (opts.onExecuteCommand && toolCall.tool === 'execute_command') {
962
- // Delegate to external terminal (e.g. Zed ACP terminal)
963
- // Note: onExecuteCommand runs after the permission gate above
964
- const command = toolCall.parameters.command;
965
- const args = toolCall.parameters.args || [];
966
- const cwd = projectContext.root || process.cwd();
967
- if (!command) {
968
- toolResult = {
969
- success: false,
970
- output: '',
971
- error: 'execute_command called with missing command field',
972
- tool: toolCall.tool,
973
- parameters: toolCall.parameters,
974
- };
975
- }
976
- else {
977
- try {
978
- const commandResult = await opts.onExecuteCommand(command, args, cwd);
979
- toolResult = {
980
- success: commandResult.exitCode === 0,
981
- output: commandResult.stdout || '(no output)',
982
- error: commandResult.exitCode !== 0 ? (commandResult.stderr || `exited with code ${commandResult.exitCode}`) : undefined,
983
- tool: toolCall.tool,
984
- parameters: toolCall.parameters,
985
- };
986
- }
987
- catch (err) {
988
- debug('onExecuteCommand callback threw, falling back to local execution:', err);
989
- // Fallback to local execution if callback throws
990
- toolResult = await executeTool(toolCall, cwd, opts.fs, opts.mcpSessionId);
991
- }
992
- }
993
- }
994
- else {
995
- toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
996
- }
997
- opts.onToolResult?.(toolResult, toolCall);
998
- // Log action
999
- const actionLog = createActionLog(toolCall, toolResult);
1000
- actions.push(actionLog);
1001
- // createActionLog already classified this; reuse its verdict rather than
1002
- // re-deriving the action type in a second place that could drift.
1003
- recordAuditEvent(auditRoot, {
1004
- ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
1005
- target: describeAuditTarget(toolCall),
1006
- outcome: toolResult.success ? 'ok' : 'error',
1007
- detail: toolResult.success ? undefined : toolResult.error,
1008
- });
1009
1154
  // ── Infinite loop detection for write/edit ──────────────────────────
1010
1155
  if (toolCall.tool === 'write_file' || toolCall.tool === 'edit_file') {
1011
1156
  const filePath = toolCall.parameters.path || '';
@@ -1069,7 +1214,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
1069
1214
  }
1070
1215
  // Check if we hit max iterations — build partial summary from actions log
1071
1216
  if (iteration >= opts.maxIterations && !finalResponse) {
1072
- result = buildPausedResult('iteration_limit', { iterations: iteration, actions, maxIterations: opts.maxIterations });
1217
+ result = asNotice(buildPausedResult('iteration_limit', { iterations: iteration, actions, maxIterations: opts.maxIterations }));
1073
1218
  if (!opts.nested)
1074
1219
  writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
1075
1220
  return result;
@@ -1088,12 +1233,20 @@ export async function runAgent(prompt, projectContext, options = {}) {
1088
1233
  runLint: false,
1089
1234
  };
1090
1235
  const hasPermittedVerification = verificationPolicy.runBuild || verificationPolicy.runTest || verificationPolicy.runTypecheck;
1236
+ // Set when the checks still fail as verification stops, whatever stopped
1237
+ // it: attempts used up, the step limit, or a fix request that errored.
1238
+ let verificationFailure;
1239
+ let stillFailing = [];
1091
1240
  if (autoVerify !== 'off' && !opts.dryRun && hasPermittedVerification) {
1092
1241
  // Check if we made any file changes worth verifying
1093
1242
  const hasFileChanges = actions.some(a => a.type === 'write' || a.type === 'edit' || a.type === 'delete');
1094
1243
  if (hasFileChanges) {
1095
1244
  let fixAttempt = 0;
1096
1245
  let previousErrorSignature = '';
1246
+ // The latest failing verification, cleared once a later one passes.
1247
+ let unresolved = null;
1248
+ // Set once a verification finishes with no failing check.
1249
+ let verified = false;
1097
1250
  while (fixAttempt < maxFixAttempts) {
1098
1251
  // Check abort signal
1099
1252
  if (opts.abortSignal?.aborted) {
@@ -1101,45 +1254,66 @@ export async function runAgent(prompt, projectContext, options = {}) {
1101
1254
  }
1102
1255
  opts.onIteration?.(iteration, `Verification attempt ${fixAttempt + 1}/${maxFixAttempts}`);
1103
1256
  // Run verifications based on selected mode
1104
- const verifyResults = await runAllVerifications(projectContext.root || process.cwd(), verificationPolicy);
1257
+ const verifyResults = await runAllVerifications(projectContext.root || process.cwd(), {
1258
+ ...verificationPolicy,
1259
+ signal: opts.abortSignal,
1260
+ });
1261
+ // Stopping kills the running checks, which then read as "could not
1262
+ // run". That is not a verdict: the run was stopped (handled below).
1263
+ if (opts.abortSignal?.aborted)
1264
+ break;
1105
1265
  opts.onVerification?.(verifyResults);
1106
- // Filter errors: only keep those related to files the agent touched
1266
+ // Point the agent at errors in files it touched, so it doesn't wander
1267
+ // off into unrelated code. This only narrows what the agent is shown;
1268
+ // a failing check stays failed. When nothing would be left — every
1269
+ // error is in a file this run didn't touch (a rename breaks an
1270
+ // importer, a test file fails) or the output couldn't be parsed —
1271
+ // the full list stays, since hiding it would pass a broken build.
1107
1272
  const touchedFiles = new Set(actions
1108
1273
  .filter(a => a.type === 'write' || a.type === 'edit')
1109
1274
  .map(a => a.target));
1275
+ let errorsOutsideTouchedFiles = false;
1110
1276
  for (const vr of verifyResults) {
1111
- vr.errors = vr.errors.filter(e => {
1277
+ if (vr.success)
1278
+ continue;
1279
+ const related = vr.errors.filter(e => {
1112
1280
  if (!e.file)
1113
1281
  return true; // Keep errors without file info (build failures etc)
1114
1282
  return touchedFiles.has(e.file) || [...touchedFiles].some(f => e.file.endsWith(f) || f.endsWith(e.file));
1115
1283
  });
1116
- // Update success based on remaining errors
1117
- if (vr.errors.filter(e => e.severity === 'error').length === 0) {
1118
- vr.success = true;
1284
+ if (related.some(e => e.severity === 'error')) {
1285
+ vr.errors = related;
1286
+ }
1287
+ else if (vr.errors.some(e => e.file)) {
1288
+ errorsOutsideTouchedFiles = true;
1119
1289
  }
1120
1290
  }
1121
- // Check if all passed (after filtering)
1291
+ // Checks that could not run are reported, not fixed: there is
1292
+ // nothing in them for the model to act on, and they say nothing
1293
+ // about whether the change is right.
1122
1294
  if (!hasVerificationErrors(verifyResults)) {
1295
+ unresolved = null;
1296
+ verified = true;
1123
1297
  const summary = getVerificationSummary(verifyResults);
1124
- finalResponse += `\n\n✓ Verification passed: ${summary.passed}/${summary.total} checks`;
1298
+ if (summary.passed > 0) {
1299
+ appendToResponse(`\n\n✓ Verification passed: ${summary.passed}/${summary.total} checks`);
1300
+ }
1301
+ const notRun = describeChecksNotRun(verifyResults);
1302
+ if (notRun)
1303
+ appendToResponse(`\n\n${notRun}`);
1125
1304
  break;
1126
1305
  }
1306
+ unresolved = verifyResults;
1127
1307
  fixAttempt++;
1128
- // If we've exceeded fix attempts, hand back to the main agent loop
1129
- // instead of stopping — let it keep working freely without the verification constraint
1308
+ // Out of attempts: stop here and report the failure below.
1130
1309
  if (fixAttempt >= maxFixAttempts) {
1131
- const errorMessage = formatErrorsForAgent(verifyResults);
1132
- messages.push({ role: 'assistant', content: finalResponse });
1133
- messages.push({
1134
- role: 'user',
1135
- content: `${errorMessage}\n\nVerification has failed ${fixAttempt} time(s). Stop trying the same approach. Step back, re-read ALL relevant files, and think about the root cause from scratch. Try a fundamentally different solution.`,
1136
- });
1137
- // Re-enter the main agent loop — it will continue until maxIterations
1138
- iteration++;
1139
1310
  break;
1140
1311
  }
1141
1312
  // Detect if the same errors are repeating (previous fix attempt didn't help)
1142
- const errorMessage = formatErrorsForAgent(verifyResults);
1313
+ let errorMessage = formatErrorsForAgent(verifyResults);
1314
+ if (errorsOutsideTouchedFiles) {
1315
+ errorMessage += '\n\nSome of these errors are in files you did not change and may predate this task. Fix them only if your change caused them.';
1316
+ }
1143
1317
  const currentErrorSignature = errorMessage.slice(0, 200);
1144
1318
  const errorsRepeating = previousErrorSignature !== '' && currentErrorSignature === previousErrorSignature;
1145
1319
  previousErrorSignature = currentErrorSignature;
@@ -1170,58 +1344,22 @@ export async function runAgent(prompt, projectContext, options = {}) {
1170
1344
  if (fixToolCalls.length === 0) {
1171
1345
  // Agent gave up or thinks it's fixed
1172
1346
  finalResponse = fixContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
1347
+ appended = '';
1173
1348
  continue; // Re-run verification
1174
1349
  }
1175
1350
  // Execute fix tool calls
1176
1351
  messages.push({ role: 'assistant', content: fixContent });
1177
1352
  const fixResults = [];
1178
1353
  for (const toolCall of fixToolCalls) {
1354
+ if (opts.abortSignal?.aborted)
1355
+ break;
1179
1356
  opts.onToolCall?.(toolCall);
1180
- if (activePersonality && !isPersonalityToolCallAllowed(activePersonality, toolCall, registeredMcpToolNames)) {
1181
- const denied = {
1182
- success: false,
1183
- output: '',
1184
- error: `Tool "${toolCall.tool}" is blocked by custom bot "${activePersonality.displayName}".`,
1185
- tool: toolCall.tool,
1186
- parameters: toolCall.parameters,
1187
- };
1188
- opts.onToolResult?.(denied, toolCall);
1189
- actions.push(createActionLog(toolCall, denied));
1190
- // The one event nothing recorded before. A boundary you cannot audit
1191
- // is a boundary you have to take on faith.
1192
- recordAuditEvent(auditRoot, {
1193
- ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
1194
- target: describeAuditTarget(toolCall), outcome: 'refused',
1195
- detail: `blocked by custom bot "${activePersonality.displayName}"`,
1196
- });
1197
- fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
1198
- continue;
1199
- }
1200
- if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
1201
- const denied = {
1202
- success: false,
1203
- output: '',
1204
- error: `Tool "${toolCall.tool}" is not available to this sub-agent.`,
1205
- tool: toolCall.tool,
1206
- parameters: toolCall.parameters,
1207
- };
1208
- opts.onToolResult?.(denied, toolCall);
1209
- actions.push(createActionLog(toolCall, denied));
1210
- fixResults.push(`Tool ${toolCall.tool} is not allowed for this sub-agent.`);
1357
+ // Same gates as the main loop: a fix is still a tool call.
1358
+ const { result: toolResult, refusal } = await dispatchToolCall(toolCall);
1359
+ if (refusal) {
1360
+ fixResults.push(refusal);
1211
1361
  continue;
1212
1362
  }
1213
- const toolResult = await executeTool(toolCall, projectContext.root || process.cwd(), opts.fs, opts.mcpSessionId);
1214
- opts.onToolResult?.(toolResult, toolCall);
1215
- const actionLog = createActionLog(toolCall, toolResult);
1216
- actions.push(actionLog);
1217
- // createActionLog already classified this; reuse its verdict rather than
1218
- // re-deriving the action type in a second place that could drift.
1219
- recordAuditEvent(auditRoot, {
1220
- ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
1221
- target: describeAuditTarget(toolCall),
1222
- outcome: toolResult.success ? 'ok' : 'error',
1223
- detail: toolResult.success ? undefined : toolResult.error,
1224
- });
1225
1363
  if (toolResult.success) {
1226
1364
  const truncated = truncateToolResult(toolResult.output, toolCall.tool);
1227
1365
  fixResults.push(`Tool ${toolCall.tool} succeeded:\n${truncated}`);
@@ -1240,6 +1378,32 @@ export async function runAgent(prompt, projectContext, options = {}) {
1240
1378
  break;
1241
1379
  }
1242
1380
  }
1381
+ // Stopped by the user before verification had its answer: that is a
1382
+ // stopped run, whatever the last check said.
1383
+ if (!verified && opts.abortSignal?.aborted) {
1384
+ debug('Agent aborted during verification');
1385
+ const stopped = finalResponse ? '\n\nAgent was stopped by user before verification finished' : 'Agent was stopped by user';
1386
+ result = {
1387
+ success: false,
1388
+ iterations: iteration,
1389
+ actions,
1390
+ finalResponse: `${finalResponse}${stopped}`,
1391
+ aborted: true,
1392
+ unstreamedText: `${appended}${stopped}`.trim(),
1393
+ };
1394
+ return result;
1395
+ }
1396
+ // Never end a run whose checks still fail as if it had passed: say so
1397
+ // in the response and fail the run, so nothing downstream (the
1398
+ // completion notice, auto-commit, the progress log) treats it as done.
1399
+ if (unresolved) {
1400
+ appendToResponse(`\n\n${describeVerificationFailure(unresolved)}`);
1401
+ const notRun = describeChecksNotRun(unresolved);
1402
+ if (notRun)
1403
+ appendToResponse(`\n\n${notRun}`);
1404
+ stillFailing = failedChecks(unresolved).map(r => r.command);
1405
+ verificationFailure = `Verification failed: ${stillFailing.join(', ')}`;
1406
+ }
1243
1407
  }
1244
1408
  }
1245
1409
  // Pipeline (Phase 2): optional automatic review pass. After a top-level run
@@ -1262,17 +1426,19 @@ export async function runAgent(prompt, projectContext, options = {}) {
1262
1426
  });
1263
1427
  const body = (review.output || '').replace(/^\[reviewer\]\s*/, '').trim();
1264
1428
  if (body)
1265
- finalResponse += `\n\n---\n### Auto-review (reviewer)\n${body}`;
1429
+ appendToResponse(`\n\n---\n### Auto-review (reviewer)\n${body}`);
1266
1430
  }
1267
1431
  catch {
1268
1432
  // A failed review must never fail the run.
1269
1433
  }
1270
1434
  }
1271
1435
  result = {
1272
- success: true,
1436
+ success: !verificationFailure,
1273
1437
  iterations: iteration,
1274
1438
  actions,
1275
1439
  finalResponse,
1440
+ ...(verificationFailure ? { error: verificationFailure, failedChecks: stillFailing } : {}),
1441
+ unstreamedText: appended.trim(),
1276
1442
  };
1277
1443
  if (!opts.nested)
1278
1444
  writeProgressLog(projectContext.root || '', prompt, result, projectContext.name);
@@ -1281,13 +1447,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
1281
1447
  catch (error) {
1282
1448
  const err = error;
1283
1449
  auditFailure = err.message;
1284
- result = {
1450
+ result = asNotice({
1285
1451
  success: false,
1286
1452
  iterations: iteration,
1287
1453
  actions,
1288
1454
  finalResponse: '',
1289
1455
  error: err.message,
1290
- };
1456
+ });
1291
1457
  return result;
1292
1458
  }
1293
1459
  finally {
@@ -1354,15 +1520,17 @@ export function getAgentHistory() {
1354
1520
  }));
1355
1521
  }
1356
1522
  /**
1357
- * Get current session actions
1523
+ * Actions of the run undo acts on (see getCurrentSession). Pass the workspace
1524
+ * to leave out a run in another one. `result` is 'undone' for an action that
1525
+ * has since been undone, 'success' otherwise.
1358
1526
  */
1359
- export function getCurrentSessionActions() {
1360
- const session = getCurrentSession();
1527
+ export function getCurrentSessionActions(projectRoot) {
1528
+ const session = getCurrentSession(projectRoot);
1361
1529
  if (!session)
1362
1530
  return [];
1363
1531
  return session.actions.map(a => ({
1364
1532
  type: a.type,
1365
1533
  target: a.path || '',
1366
- result: 'success',
1534
+ result: a.undone ? 'undone' : 'success',
1367
1535
  }));
1368
1536
  }