@visorcraft/idlehands 1.3.8 → 1.3.10

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/dist/agent.js CHANGED
@@ -1,29 +1,29 @@
1
- import { normalizeApprovalMode } from './shared/config-utils.js';
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { generateMinimalDiff, toolResultSummary, execCommandFromSig, formatDurationMs, looksLikePlanningNarration, capTextByApproxTokens, isLikelyBinaryBuffer, sanitizePathsInMessage, digestToolResult, } from './agent/formatting.js';
4
+ import { reviewArtifactKeys, looksLikeCodeReviewRequest, looksLikeReviewRetrievalRequest, retrievalAllowsStaleArtifact, parseReviewArtifactStalePolicy, parseReviewArtifact, reviewArtifactStaleReason, gitHead, normalizeModelsResponse, } from './agent/review-artifact.js';
5
+ import { parseToolCallsFromContent, getMissingRequiredParams, getArgValidationIssues, stripMarkdownFences, parseJsonArgs, } from './agent/tool-calls.js';
6
+ import { ToolLoopGuard } from './agent/tool-loop-guard.js';
2
7
  import { OpenAIClient } from './client.js';
3
- import { enforceContextBudget, stripThinking, estimateTokensFromMessages, estimateToolSchemaTokens } from './history.js';
4
- import * as tools from './tools.js';
5
- import { selectHarness } from './harnesses.js';
6
- import { BASE_MAX_TOKENS, deriveContextWindow, deriveGenerationParams, supportsVisionModel } from './model-customization.js';
7
- import { HookManager, loadHookPlugins } from './hooks/index.js';
8
- import { checkExecSafety, checkPathSafety } from './safety.js';
9
8
  import { loadProjectContext } from './context.js';
10
9
  import { loadGitContext, isGitDirty, stashWorkingTree } from './git.js';
10
+ import { selectHarness } from './harnesses.js';
11
+ import { enforceContextBudget, stripThinking, estimateTokensFromMessages, estimateToolSchemaTokens, } from './history.js';
12
+ import { HookManager, loadHookPlugins } from './hooks/index.js';
11
13
  import { projectIndexKeys, parseIndexMeta, isFreshIndex, indexSummaryLine } from './indexer.js';
12
- import { ReplayStore } from './replay.js';
13
- import { VaultStore } from './vault.js';
14
14
  import { LensStore } from './lens.js';
15
- import { SYS_CONTEXT_SCHEMA, collectSnapshot } from './sys/context.js';
16
- import { MCPManager } from './mcp.js';
17
15
  import { LspManager, detectInstalledLspServers } from './lsp.js';
18
- import { generateMinimalDiff, toolResultSummary, execCommandFromSig, formatDurationMs, looksLikePlanningNarration, capTextByApproxTokens, isLikelyBinaryBuffer, sanitizePathsInMessage, digestToolResult, } from './agent/formatting.js';
19
- import { parseToolCallsFromContent, getMissingRequiredParams, getArgValidationIssues, stripMarkdownFences, parseJsonArgs } from './agent/tool-calls.js';
16
+ import { MCPManager } from './mcp.js';
17
+ import { BASE_MAX_TOKENS, deriveContextWindow, deriveGenerationParams, supportsVisionModel, } from './model-customization.js';
18
+ import { ReplayStore } from './replay.js';
19
+ import { checkExecSafety, checkPathSafety } from './safety.js';
20
+ import { normalizeApprovalMode } from './shared/config-utils.js';
21
+ import { SYS_CONTEXT_SCHEMA, collectSnapshot } from './sys/context.js';
20
22
  import { ToolError, ValidationError } from './tools/tool-error.js';
21
- import { ToolLoopGuard } from './agent/tool-loop-guard.js';
22
- export { parseToolCallsFromContent };
23
- import { reviewArtifactKeys, looksLikeCodeReviewRequest, looksLikeReviewRetrievalRequest, retrievalAllowsStaleArtifact, parseReviewArtifactStalePolicy, parseReviewArtifact, reviewArtifactStaleReason, gitHead, normalizeModelsResponse, } from './agent/review-artifact.js';
24
- import fs from 'node:fs/promises';
25
- import path from 'node:path';
23
+ import * as tools from './tools.js';
26
24
  import { stateDir, timestampedId } from './utils.js';
25
+ import { VaultStore } from './vault.js';
26
+ export { parseToolCallsFromContent };
27
27
  function makeAbortController() {
28
28
  // Node 24: AbortController is global.
29
29
  return new AbortController();
@@ -32,7 +32,9 @@ const CACHED_EXEC_OBSERVATION_HINT = '[idlehands hint] Reused cached output for
32
32
  function looksLikeReadOnlyExecCommand(command) {
33
33
  // Strip leading `cd <path> &&` / `cd <path>;` prefixes — cd is read-only
34
34
  // navigation, the actual command that matters comes after.
35
- let cmd = String(command || '').trim().toLowerCase();
35
+ let cmd = String(command || '')
36
+ .trim()
37
+ .toLowerCase();
36
38
  if (!cmd)
37
39
  return false;
38
40
  cmd = cmd.replace(/^(\s*cd\s+[^;&|]+\s*(?:&&|;)\s*)+/i, '').trim();
@@ -158,48 +160,66 @@ class AgentLoopBreak extends Error {
158
160
  this.name = 'AgentLoopBreak';
159
161
  }
160
162
  }
161
- const SYSTEM_PROMPT = `You are a coding agent with filesystem and shell access. Execute the user's request using the provided tools.
162
-
163
- Rules:
164
- - Work in the current directory. Use relative paths for all file operations.
165
- - Do the work directly. Do NOT use spawn_task to delegate the user's primary request — only use it for genuinely independent subtasks that benefit from parallel execution.
166
- - Never use spawn_task to bypass confirmation/safety restrictions (for example blocked package installs). If a command is blocked, adapt the plan or ask the user for approval mode changes.
167
- - Read the target file before editing. You need the exact text for search/replace.
168
- - Use read_file with search=... to jump to relevant code; avoid reading whole files.
169
- - Never call read_file/read_files/list_dir twice in a row with identical arguments (same path/options). Reuse the previous result instead.
170
- - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
171
- - Tool-call arguments MUST be strict JSON (double-quoted keys/strings, no comments, no trailing commas).
172
- - edit_range example: {"path":"src/foo.ts","start_line":10,"end_line":14,"replacement":"line A\nline B"}
173
- - apply_patch example: {"patch":"--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -10,2 +10,2 @@\n-old\n+new","files":["src/foo.ts"]}
174
- - write_file is for new files or explicit full rewrites only. Existing non-empty files require overwrite=true/force=true.
175
- - Use insert_file for insertions (prepend/append/line).
176
- - Use exec to run commands, tests, builds; check results before reporting success.
177
- - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd". Each exec call is a fresh shell; cd does not persist.
178
- - Batch work: read all files you need, then apply all edits, then verify.
179
- - Be concise. Report what you changed and why.
180
- - Do NOT read every file in a directory. Use search_files or exec with grep to locate relevant code first, then read only the files that match.
181
- - If search_files returns 0 matches, try a broader pattern or use: exec grep -rn "keyword" path/
182
- - Anton (the autonomous task runner) is ONLY activated when the user explicitly invokes /anton. Never self-activate as Anton or start processing task files on your own.
183
-
184
- Tool call format:
185
- - Use tool_calls. Do not write JSON tool invocations in your message text.
163
+ const SYSTEM_PROMPT = `You are a coding agent with filesystem and shell access. Execute the user's request using the provided tools.
164
+
165
+ Rules:
166
+ - Work in the current directory. Use relative paths for all file operations.
167
+ - Do the work directly. Do NOT use spawn_task to delegate the user's primary request — only use it for genuinely independent subtasks that benefit from parallel execution.
168
+ - Never use spawn_task to bypass confirmation/safety restrictions (for example blocked package installs). If a command is blocked, adapt the plan or ask the user for approval mode changes.
169
+ - Read the target file before editing. You need the exact text for search/replace.
170
+ - Use read_file with search=... to jump to relevant code; avoid reading whole files.
171
+ - Never call read_file/read_files/list_dir twice in a row with identical arguments (same path/options). Reuse the previous result instead.
172
+ - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
173
+ - Tool-call arguments MUST be strict JSON (double-quoted keys/strings, no comments, no trailing commas).
174
+ - edit_range example: {"path":"src/foo.ts","start_line":10,"end_line":14,"replacement":"line A\nline B"}
175
+ - apply_patch example: {"patch":"--- a/src/foo.ts\n+++ b/src/foo.ts\n@@ -10,2 +10,2 @@\n-old\n+new","files":["src/foo.ts"]}
176
+ - write_file is for new files or explicit full rewrites only. Existing non-empty files require overwrite=true/force=true.
177
+ - Use insert_file for insertions (prepend/append/line).
178
+ - Use exec to run commands, tests, builds; check results before reporting success.
179
+ - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd". Each exec call is a fresh shell; cd does not persist.
180
+ - Batch work: read all files you need, then apply all edits, then verify.
181
+ - Be concise. Report what you changed and why.
182
+ - Do NOT read every file in a directory. Use search_files or exec with grep to locate relevant code first, then read only the files that match.
183
+ - If search_files returns 0 matches, try a broader pattern or use: exec grep -rn "keyword" path/
184
+ - Anton (the autonomous task runner) is ONLY activated when the user explicitly invokes /anton. Never self-activate as Anton or start processing task files on your own.
185
+
186
+ Tool call format:
187
+ - Use tool_calls. Do not write JSON tool invocations in your message text.
186
188
  `;
187
189
  const MCP_TOOLS_REQUEST_TOKEN = '[[MCP_TOOLS_REQUEST]]';
188
- const DEFAULT_SUB_AGENT_SYSTEM_PROMPT = `You are a focused coding sub-agent. Execute only the delegated task.
189
- - Work in the current directory. Use relative paths for all file operations.
190
- - Read the target file before editing. You need the exact text for search/replace.
191
- - Keep tool usage tight and efficient.
192
- - Prefer surgical edits over rewrites.
193
- - Do NOT create files outside the working directory unless explicitly requested.
194
- - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd".
195
- - Run verification commands when relevant.
190
+ const DEFAULT_SUB_AGENT_SYSTEM_PROMPT = `You are a focused coding sub-agent. Execute only the delegated task.
191
+ - Work in the current directory. Use relative paths for all file operations.
192
+ - Read the target file before editing. You need the exact text for search/replace.
193
+ - Keep tool usage tight and efficient.
194
+ - Prefer surgical edits over rewrites.
195
+ - Do NOT create files outside the working directory unless explicitly requested.
196
+ - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd".
197
+ - Run verification commands when relevant.
196
198
  - Return a concise outcome summary.`;
197
199
  const DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP = 4000;
198
- const LSP_TOOL_NAMES = ['lsp_diagnostics', 'lsp_symbols', 'lsp_hover', 'lsp_definition', 'lsp_references'];
200
+ const LSP_TOOL_NAMES = [
201
+ 'lsp_diagnostics',
202
+ 'lsp_symbols',
203
+ 'lsp_hover',
204
+ 'lsp_definition',
205
+ 'lsp_references',
206
+ ];
199
207
  const LSP_TOOL_NAME_SET = new Set(LSP_TOOL_NAMES);
200
- const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'edit_range', 'apply_patch', 'write_file', 'insert_file']);
208
+ const FILE_MUTATION_TOOL_SET = new Set([
209
+ 'edit_file',
210
+ 'edit_range',
211
+ 'apply_patch',
212
+ 'write_file',
213
+ 'insert_file',
214
+ ]);
201
215
  /** Approval mode permissiveness ranking (lower = more restrictive). */
202
- const APPROVAL_MODE_RANK = { plan: 0, reject: 1, default: 2, 'auto-edit': 3, yolo: 4 };
216
+ const APPROVAL_MODE_RANK = {
217
+ plan: 0,
218
+ reject: 1,
219
+ default: 2,
220
+ 'auto-edit': 3,
221
+ yolo: 4,
222
+ };
203
223
  /**
204
224
  * Cap a sub-agent's approval mode at the parent's level.
205
225
  * Sub-agents cannot escalate beyond the parent's approval mode.
@@ -284,11 +304,15 @@ function buildToolsSchema(opts) {
284
304
  type: 'object',
285
305
  additionalProperties: false,
286
306
  properties,
287
- required
307
+ required,
288
308
  });
289
309
  const str = () => ({ type: 'string' });
290
310
  const bool = () => ({ type: 'boolean' });
291
- const int = (min, max) => ({ type: 'integer', ...(min !== undefined && { minimum: min }), ...(max !== undefined && { maximum: max }) });
311
+ const int = (min, max) => ({
312
+ type: 'integer',
313
+ ...(min !== undefined && { minimum: min }),
314
+ ...(max !== undefined && { maximum: max }),
315
+ });
292
316
  const schemas = [
293
317
  // ────────────────────────────────────────────────────────────────────────────
294
318
  // Token-safe reads (require limit; allow plain output without per-line numbers)
@@ -338,7 +362,10 @@ function buildToolsSchema(opts) {
338
362
  function: {
339
363
  name: 'write_file',
340
364
  description: 'Write file (atomic, backup). Existing non-empty files require overwrite=true (or force=true).',
341
- parameters: obj({ path: str(), content: str(), overwrite: bool(), force: bool() }, ['path', 'content']),
365
+ parameters: obj({ path: str(), content: str(), overwrite: bool(), force: bool() }, [
366
+ 'path',
367
+ 'content',
368
+ ]),
342
369
  },
343
370
  },
344
371
  {
@@ -371,7 +398,11 @@ function buildToolsSchema(opts) {
371
398
  function: {
372
399
  name: 'edit_file',
373
400
  description: 'Legacy exact replace (requires old_text). Prefer apply_patch/edit_range.',
374
- parameters: obj({ path: str(), old_text: str(), new_text: str(), replace_all: bool() }, ['path', 'old_text', 'new_text']),
401
+ parameters: obj({ path: str(), old_text: str(), new_text: str(), replace_all: bool() }, [
402
+ 'path',
403
+ 'old_text',
404
+ 'new_text',
405
+ ]),
375
406
  },
376
407
  },
377
408
  {
@@ -398,7 +429,10 @@ function buildToolsSchema(opts) {
398
429
  function: {
399
430
  name: 'search_files',
400
431
  description: 'Search regex in files.',
401
- parameters: obj({ pattern: str(), path: str(), include: str(), max_results: int(1, 100) }, ['pattern', 'path']),
432
+ parameters: obj({ pattern: str(), path: str(), include: str(), max_results: int(1, 100) }, [
433
+ 'pattern',
434
+ 'path',
435
+ ]),
402
436
  },
403
437
  },
404
438
  // ────────────────────────────────────────────────────────────────────────────
@@ -428,18 +462,42 @@ function buildToolsSchema(opts) {
428
462
  max_tokens: int(),
429
463
  timeout_sec: int(),
430
464
  system_prompt: str(),
431
- approval_mode: { type: 'string', enum: ['plan', 'reject', 'default', 'auto-edit', 'yolo'] },
465
+ approval_mode: {
466
+ type: 'string',
467
+ enum: ['plan', 'reject', 'default', 'auto-edit', 'yolo'],
468
+ },
432
469
  }, ['task']),
433
470
  },
434
471
  });
435
472
  }
436
473
  if (opts?.activeVaultTools) {
437
- schemas.push({ type: 'function', function: { name: 'vault_search', description: 'Search vault.', parameters: obj({ query: str(), limit: int() }, ['query']) } }, { type: 'function', function: { name: 'vault_note', description: 'Write vault note.', parameters: obj({ key: str(), value: str() }, ['key', 'value']) } });
474
+ schemas.push({
475
+ type: 'function',
476
+ function: {
477
+ name: 'vault_search',
478
+ description: 'Search vault.',
479
+ parameters: obj({ query: str(), limit: int() }, ['query']),
480
+ },
481
+ }, {
482
+ type: 'function',
483
+ function: {
484
+ name: 'vault_note',
485
+ description: 'Write vault note.',
486
+ parameters: obj({ key: str(), value: str() }, ['key', 'value']),
487
+ },
488
+ });
438
489
  }
439
490
  else if (opts?.passiveVault) {
440
491
  // In passive mode, expose vault_search (read-only) so the model can recover
441
492
  // compacted context on demand, but don't expose vault_note (write).
442
- schemas.push({ type: 'function', function: { name: 'vault_search', description: 'Search vault memory for earlier context that was compacted away. Use sparingly — only when you need to recall specific details from earlier in the conversation.', parameters: obj({ query: str(), limit: int() }, ['query']) } });
493
+ schemas.push({
494
+ type: 'function',
495
+ function: {
496
+ name: 'vault_search',
497
+ description: 'Search vault memory for earlier context that was compacted away. Use sparingly — only when you need to recall specific details from earlier in the conversation.',
498
+ parameters: obj({ query: str(), limit: int() }, ['query']),
499
+ },
500
+ });
443
501
  }
444
502
  // Phase 9: sys_context tool is only available in sys mode.
445
503
  if (opts?.sysMode) {
@@ -451,36 +509,48 @@ function buildToolsSchema(opts) {
451
509
  function: {
452
510
  name: 'lsp_diagnostics',
453
511
  description: 'Get LSP diagnostics (errors/warnings) for file or project.',
454
- parameters: obj({ path: str(), severity: int() }, [])
455
- }
512
+ parameters: obj({ path: str(), severity: int() }, []),
513
+ },
456
514
  }, {
457
515
  type: 'function',
458
516
  function: {
459
517
  name: 'lsp_symbols',
460
518
  description: 'List symbols (functions, classes, vars) in a file.',
461
- parameters: obj({ path: str() }, ['path'])
462
- }
519
+ parameters: obj({ path: str() }, ['path']),
520
+ },
463
521
  }, {
464
522
  type: 'function',
465
523
  function: {
466
524
  name: 'lsp_hover',
467
525
  description: 'Get type/docs for symbol at position.',
468
- parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
469
- }
526
+ parameters: obj({ path: str(), line: int(), character: int() }, [
527
+ 'path',
528
+ 'line',
529
+ 'character',
530
+ ]),
531
+ },
470
532
  }, {
471
533
  type: 'function',
472
534
  function: {
473
535
  name: 'lsp_definition',
474
536
  description: 'Go to definition of symbol at position.',
475
- parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
476
- }
537
+ parameters: obj({ path: str(), line: int(), character: int() }, [
538
+ 'path',
539
+ 'line',
540
+ 'character',
541
+ ]),
542
+ },
477
543
  }, {
478
544
  type: 'function',
479
545
  function: {
480
546
  name: 'lsp_references',
481
547
  description: 'Find all references to symbol at position.',
482
- parameters: obj({ path: str(), line: int(), character: int(), max_results: int() }, ['path', 'line', 'character'])
483
- }
548
+ parameters: obj({ path: str(), line: int(), character: int(), max_results: int() }, [
549
+ 'path',
550
+ 'line',
551
+ 'character',
552
+ ]),
553
+ },
484
554
  });
485
555
  }
486
556
  if (opts?.mcpTools?.length) {
@@ -489,7 +559,12 @@ function buildToolsSchema(opts) {
489
559
  return schemas;
490
560
  }
491
561
  function isReadOnlyTool(name) {
492
- return name === 'read_file' || name === 'read_files' || name === 'list_dir' || name === 'search_files' || name === 'vault_search' || name === 'sys_context';
562
+ return (name === 'read_file' ||
563
+ name === 'read_files' ||
564
+ name === 'list_dir' ||
565
+ name === 'search_files' ||
566
+ name === 'vault_search' ||
567
+ name === 'sys_context');
493
568
  }
494
569
  /** Human-readable summary of what a blocked tool call would do. */
495
570
  function planModeSummary(name, args) {
@@ -544,20 +619,23 @@ export async function createSession(opts) {
544
619
  if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0) {
545
620
  client.setResponseTimeout(cfg.response_timeout);
546
621
  }
547
- if (typeof client.setConnectionTimeout === 'function' && typeof cfg.connection_timeout === 'number' && cfg.connection_timeout > 0) {
622
+ if (typeof client.setConnectionTimeout === 'function' &&
623
+ typeof cfg.connection_timeout === 'number' &&
624
+ cfg.connection_timeout > 0) {
548
625
  client.setConnectionTimeout(cfg.connection_timeout);
549
626
  }
550
- if (typeof client.setInitialConnectionCheck === 'function' && typeof cfg.initial_connection_check === 'boolean') {
627
+ if (typeof client.setInitialConnectionCheck === 'function' &&
628
+ typeof cfg.initial_connection_check === 'boolean') {
551
629
  client.setInitialConnectionCheck(cfg.initial_connection_check);
552
630
  }
553
- if (typeof client.setInitialConnectionProbeTimeout === 'function' && typeof cfg.initial_connection_timeout === 'number' && cfg.initial_connection_timeout > 0) {
631
+ if (typeof client.setInitialConnectionProbeTimeout === 'function' &&
632
+ typeof cfg.initial_connection_timeout === 'number' &&
633
+ cfg.initial_connection_timeout > 0) {
554
634
  client.setInitialConnectionProbeTimeout(cfg.initial_connection_timeout);
555
635
  }
556
636
  // Health check + model list (cheap, avoids wasting GPU on chat warmups if unreachable)
557
637
  let modelsList = normalizeModelsResponse(await client.models().catch(() => null));
558
- let model = cfg.model && cfg.model.trim().length
559
- ? cfg.model
560
- : await autoPickModel(client, modelsList);
638
+ let model = cfg.model && cfg.model.trim().length ? cfg.model : await autoPickModel(client, modelsList);
561
639
  let harness = selectHarness(model, cfg.harness && cfg.harness.trim() ? cfg.harness.trim() : undefined);
562
640
  // Try to derive context window from /v1/models (if provided by server).
563
641
  const explicitContextWindow = cfg.context_window != null;
@@ -570,19 +648,22 @@ export async function createSession(opts) {
570
648
  let supportsVision = supportsVisionModel(model, modelMeta, harness);
571
649
  const sessionId = `session-${timestampedId()}`;
572
650
  const hookCfg = cfg.hooks ?? {};
573
- const hookManager = opts.runtime?.hookManager ?? new HookManager({
574
- enabled: hookCfg.enabled !== false,
575
- strict: hookCfg.strict === true,
576
- warnMs: hookCfg.warn_ms,
577
- allowedCapabilities: Array.isArray(hookCfg.allow_capabilities) ? hookCfg.allow_capabilities : undefined,
578
- context: () => ({
579
- sessionId,
580
- cwd: projectDir,
581
- model,
582
- harness: harness.id,
583
- endpoint: cfg.endpoint,
584
- }),
585
- });
651
+ const hookManager = opts.runtime?.hookManager ??
652
+ new HookManager({
653
+ enabled: hookCfg.enabled !== false,
654
+ strict: hookCfg.strict === true,
655
+ warnMs: hookCfg.warn_ms,
656
+ allowedCapabilities: Array.isArray(hookCfg.allow_capabilities)
657
+ ? hookCfg.allow_capabilities
658
+ : undefined,
659
+ context: () => ({
660
+ sessionId,
661
+ cwd: projectDir,
662
+ model,
663
+ harness: harness.id,
664
+ endpoint: cfg.endpoint,
665
+ }),
666
+ });
586
667
  const emitDetached = (promise, eventName) => {
587
668
  void promise.catch((error) => {
588
669
  if (!process.env.IDLEHANDS_QUIET_WARNINGS) {
@@ -628,16 +709,33 @@ export async function createSession(opts) {
628
709
  const lensEnabled = cfg.trifecta?.enabled !== false && cfg.trifecta?.lens?.enabled !== false;
629
710
  const spawnTaskEnabled = opts.allowSpawnTask !== false && cfg.sub_agents?.enabled !== false;
630
711
  const mcpServers = Array.isArray(cfg.mcp?.servers) ? cfg.mcp.servers : [];
631
- const mcpEnabledTools = Array.isArray(cfg.mcp?.enabled_tools) ? cfg.mcp?.enabled_tools : undefined;
712
+ const mcpEnabledTools = Array.isArray(cfg.mcp?.enabled_tools)
713
+ ? cfg.mcp?.enabled_tools
714
+ : undefined;
632
715
  const mcpToolBudget = Number.isFinite(cfg.mcp_tool_budget)
633
716
  ? Number(cfg.mcp_tool_budget)
634
- : (Number.isFinite(cfg.mcp?.tool_budget) ? Number(cfg.mcp?.tool_budget) : 1000);
717
+ : Number.isFinite(cfg.mcp?.tool_budget)
718
+ ? Number(cfg.mcp?.tool_budget)
719
+ : 1000;
635
720
  const mcpCallTimeoutSec = Number.isFinite(cfg.mcp_call_timeout_sec)
636
721
  ? Number(cfg.mcp_call_timeout_sec)
637
- : (Number.isFinite(cfg.mcp?.call_timeout_sec) ? Number(cfg.mcp?.call_timeout_sec) : 30);
722
+ : Number.isFinite(cfg.mcp?.call_timeout_sec)
723
+ ? Number(cfg.mcp?.call_timeout_sec)
724
+ : 30;
638
725
  const builtInToolNames = [
639
- 'read_file', 'read_files', 'write_file', 'apply_patch', 'edit_range', 'edit_file', 'insert_file',
640
- 'list_dir', 'search_files', 'exec', 'vault_search', 'vault_note', 'sys_context',
726
+ 'read_file',
727
+ 'read_files',
728
+ 'write_file',
729
+ 'apply_patch',
730
+ 'edit_range',
731
+ 'edit_file',
732
+ 'insert_file',
733
+ 'list_dir',
734
+ 'search_files',
735
+ 'exec',
736
+ 'vault_search',
737
+ 'vault_note',
738
+ 'sys_context',
641
739
  ...(spawnTaskEnabled ? ['spawn_task'] : []),
642
740
  ];
643
741
  const mcpManager = mcpServers.length
@@ -693,9 +791,11 @@ export async function createSession(opts) {
693
791
  allowSpawnTask: spawnTaskEnabled,
694
792
  });
695
793
  const vault = vaultEnabled
696
- ? (opts.runtime?.vault ?? new VaultStore({
697
- immutableReviewArtifactsPerProject: cfg?.trifecta?.vault?.immutable_review_artifacts_per_project,
698
- }))
794
+ ? (opts.runtime?.vault ??
795
+ new VaultStore({
796
+ immutableReviewArtifactsPerProject: cfg?.trifecta?.vault
797
+ ?.immutable_review_artifacts_per_project,
798
+ }))
699
799
  : undefined;
700
800
  if (vault) {
701
801
  // Scope vault entries by project directory to prevent cross-project context leaks
@@ -764,12 +864,15 @@ export async function createSession(opts) {
764
864
  const lspServers = lspManager.listServers();
765
865
  const running = lspServers.filter((s) => s.running).length;
766
866
  sessionMeta += `\n\n[LSP] ${running} language server(s) active: ${lspServers.map((s) => `${s.language} (${s.command})`).join(', ')}.`;
767
- sessionMeta += '\n[LSP] Use lsp_diagnostics, lsp_symbols, lsp_hover, lsp_definition, lsp_references tools for semantic code intelligence.';
867
+ sessionMeta +=
868
+ '\n[LSP] Use lsp_diagnostics, lsp_symbols, lsp_hover, lsp_definition, lsp_references tools for semantic code intelligence.';
768
869
  if (lensEnabled) {
769
- sessionMeta += '\n[LSP+Lens] lsp_symbols combines semantic symbol data with structural Lens context when available.';
870
+ sessionMeta +=
871
+ '\n[LSP+Lens] lsp_symbols combines semantic symbol data with structural Lens context when available.';
770
872
  }
771
873
  if (lspCfg?.proactive_diagnostics !== false) {
772
- sessionMeta += '\n[LSP] Proactive diagnostics enabled: errors will be reported automatically after file edits.';
874
+ sessionMeta +=
875
+ '\n[LSP] Proactive diagnostics enabled: errors will be reported automatically after file edits.';
773
876
  }
774
877
  }
775
878
  if (mcpManager) {
@@ -809,21 +912,26 @@ export async function createSession(opts) {
809
912
  if (harness.quirks.needsExplicitToolCallFormatReminder) {
810
913
  if (client.contentModeToolCalls) {
811
914
  // In content mode, tell the model to use JSON tool calls in its output
812
- sessionMeta += '\n\nYou have access to the following tools. To call a tool, output a JSON block in your response like this:\n```json\n{"name": "tool_name", "arguments": {"param": "value"}}\n```\nAvailable tools:\n';
915
+ sessionMeta +=
916
+ '\n\nYou have access to the following tools. To call a tool, output a JSON block in your response like this:\n```json\n{"name": "tool_name", "arguments": {"param": "value"}}\n```\nAvailable tools:\n';
813
917
  const toolSchemas = getToolsSchema();
814
918
  for (const t of toolSchemas) {
815
919
  const fn = t.function;
816
920
  if (fn) {
817
921
  const params = fn.parameters?.properties
818
- ? Object.entries(fn.parameters.properties).map(([k, v]) => `${k}: ${v.type ?? 'any'}`).join(', ')
922
+ ? Object.entries(fn.parameters.properties)
923
+ .map(([k, v]) => `${k}: ${v.type ?? 'any'}`)
924
+ .join(', ')
819
925
  : '';
820
926
  sessionMeta += `- ${fn.name}(${params}): ${fn.description ?? ''}\n`;
821
927
  }
822
928
  }
823
- sessionMeta += '\nIMPORTANT: Output tool calls as JSON blocks in your message. Do NOT use the tool_calls API mechanism.\nIf you use XML/function tags (e.g. <function=name>), include a full JSON object of arguments between braces.';
929
+ sessionMeta +=
930
+ '\nIMPORTANT: Output tool calls as JSON blocks in your message. Do NOT use the tool_calls API mechanism.\nIf you use XML/function tags (e.g. <function=name>), include a full JSON object of arguments between braces.';
824
931
  }
825
932
  else {
826
- sessionMeta += '\n\nIMPORTANT: Use the tool_calls mechanism to invoke tools. Do NOT write JSON tool invocations in your message text.';
933
+ sessionMeta +=
934
+ '\n\nIMPORTANT: Use the tool_calls mechanism to invoke tools. Do NOT write JSON tool invocations in your message text.';
827
935
  }
828
936
  // One-time tool-call template smoke test (first ask() call only, skip in content mode)
829
937
  if (!client.contentModeToolCalls && !client.__toolCallSmokeTested) {
@@ -859,9 +967,7 @@ export async function createSession(opts) {
859
967
  }
860
968
  return p;
861
969
  };
862
- let messages = [
863
- { role: 'system', content: buildEffectiveSystemPrompt() }
864
- ];
970
+ let messages = [{ role: 'system', content: buildEffectiveSystemPrompt() }];
865
971
  let sessionMetaPending = sessionMeta;
866
972
  const setSystemPrompt = (prompt) => {
867
973
  const next = String(prompt ?? '').trim();
@@ -883,9 +989,7 @@ export async function createSession(opts) {
883
989
  };
884
990
  const reset = () => {
885
991
  const effective = buildEffectiveSystemPrompt();
886
- messages = [
887
- { role: 'system', content: effective }
888
- ];
992
+ messages = [{ role: 'system', content: effective }];
889
993
  sessionMetaPending = sessionMeta;
890
994
  lastEditedPath = undefined;
891
995
  initialConnectionProbeDone = false;
@@ -962,7 +1066,9 @@ export async function createSession(opts) {
962
1066
  }
963
1067
  // Prevent using delegation to bypass package-install confirmation restrictions.
964
1068
  const taskSafety = checkExecSafety(task);
965
- if (!cfg.no_confirm && taskSafety.tier === 'cautious' && taskSafety.reason === 'package install/remove') {
1069
+ if (!cfg.no_confirm &&
1070
+ taskSafety.tier === 'cautious' &&
1071
+ taskSafety.reason === 'package install/remove') {
966
1072
  throw new Error('spawn_task: blocked — package install/remove is restricted in the current approval mode. ' +
967
1073
  'Do not delegate this to bypass confirmation requirements; ask the user to run with --no-confirm/--yolo instead.');
968
1074
  }
@@ -971,39 +1077,43 @@ export async function createSession(opts) {
971
1077
  const emitStatus = options?.emitStatus ?? (() => { });
972
1078
  const maxIterations = Number.isFinite(args?.max_iterations)
973
1079
  ? Math.max(1, Math.floor(Number(args.max_iterations)))
974
- : (Number.isFinite(defaults.max_iterations)
1080
+ : Number.isFinite(defaults.max_iterations)
975
1081
  ? Math.max(1, Math.floor(Number(defaults.max_iterations)))
976
- : 50);
1082
+ : 50;
977
1083
  const timeoutSec = Number.isFinite(args?.timeout_sec)
978
1084
  ? Math.max(1, Math.floor(Number(args.timeout_sec)))
979
- : (Number.isFinite(defaults.timeout_sec)
1085
+ : Number.isFinite(defaults.timeout_sec)
980
1086
  ? Math.max(1, Math.floor(Number(defaults.timeout_sec)))
981
- : Math.max(60, cfg.timeout));
1087
+ : Math.max(60, cfg.timeout);
982
1088
  const subMaxTokens = Number.isFinite(args?.max_tokens)
983
1089
  ? Math.max(128, Math.floor(Number(args.max_tokens)))
984
- : (Number.isFinite(defaults.max_tokens)
1090
+ : Number.isFinite(defaults.max_tokens)
985
1091
  ? Math.max(128, Math.floor(Number(defaults.max_tokens)))
986
- : maxTokens);
1092
+ : maxTokens;
987
1093
  const resultTokenCap = Number.isFinite(defaults.result_token_cap)
988
1094
  ? Math.max(256, Math.floor(Number(defaults.result_token_cap)))
989
1095
  : DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP;
990
1096
  const parentApproval = cfg.approval_mode ?? 'default';
991
- const rawApproval = normalizeApprovalMode(args?.approval_mode)
992
- ?? normalizeApprovalMode(defaults.approval_mode)
993
- ?? parentApproval;
1097
+ const rawApproval = normalizeApprovalMode(args?.approval_mode) ??
1098
+ normalizeApprovalMode(defaults.approval_mode) ??
1099
+ parentApproval;
994
1100
  // Sub-agents cannot escalate beyond the parent's approval mode.
995
1101
  const approvalMode = capApprovalMode(rawApproval, parentApproval);
996
1102
  const requestedModel = typeof args?.model === 'string' && args.model.trim()
997
1103
  ? args.model.trim()
998
- : (typeof defaults.model === 'string' && defaults.model.trim() ? defaults.model.trim() : model);
1104
+ : typeof defaults.model === 'string' && defaults.model.trim()
1105
+ ? defaults.model.trim()
1106
+ : model;
999
1107
  const requestedEndpoint = typeof args?.endpoint === 'string' && args.endpoint.trim()
1000
1108
  ? args.endpoint.trim()
1001
- : (typeof defaults.endpoint === 'string' && defaults.endpoint.trim() ? defaults.endpoint.trim() : cfg.endpoint);
1109
+ : typeof defaults.endpoint === 'string' && defaults.endpoint.trim()
1110
+ ? defaults.endpoint.trim()
1111
+ : cfg.endpoint;
1002
1112
  const requestedSystemPrompt = typeof args?.system_prompt === 'string' && args.system_prompt.trim()
1003
1113
  ? args.system_prompt.trim()
1004
- : (typeof defaults.system_prompt === 'string' && defaults.system_prompt.trim()
1114
+ : typeof defaults.system_prompt === 'string' && defaults.system_prompt.trim()
1005
1115
  ? defaults.system_prompt.trim()
1006
- : DEFAULT_SUB_AGENT_SYSTEM_PROMPT);
1116
+ : DEFAULT_SUB_AGENT_SYSTEM_PROMPT;
1007
1117
  const cwd = projectDir;
1008
1118
  const ctxFiles = await buildSubAgentContextBlock(cwd, args?.context_files);
1009
1119
  let delegatedInstruction = task;
@@ -1108,7 +1218,9 @@ export async function createSession(opts) {
1108
1218
  `turns: ${subTurns}`,
1109
1219
  `tool_calls: ${subToolCalls}`,
1110
1220
  `files_changed: ${filesChanged.length ? filesChanged.join(', ') : 'none'}`,
1111
- capped.truncated ? `[sub-agent] summarized result capped to ~${resultTokenCap} tokens` : `[sub-agent] summarized result within cap`,
1221
+ capped.truncated
1222
+ ? `[sub-agent] summarized result capped to ~${resultTokenCap} tokens`
1223
+ : `[sub-agent] summarized result within cap`,
1112
1224
  `result:\n${capped.text}`,
1113
1225
  ].join('\n');
1114
1226
  });
@@ -1117,7 +1229,10 @@ export async function createSession(opts) {
1117
1229
  const buildToolCtx = (overrides) => {
1118
1230
  const defaultConfirmBridge = opts.confirmProvider
1119
1231
  ? async (prompt) => opts.confirmProvider.confirm({
1120
- tool: '', args: {}, summary: prompt, mode: cfg.approval_mode,
1232
+ tool: '',
1233
+ args: {},
1234
+ summary: prompt,
1235
+ mode: cfg.approval_mode,
1121
1236
  })
1122
1237
  : opts.confirm;
1123
1238
  return {
@@ -1136,7 +1251,10 @@ export async function createSession(opts) {
1136
1251
  vault,
1137
1252
  lens,
1138
1253
  signal: overrides?.signal ?? inFlight?.signal,
1139
- onMutation: overrides?.onMutation ?? ((absPath) => { lastEditedPath = absPath; }),
1254
+ onMutation: overrides?.onMutation ??
1255
+ ((absPath) => {
1256
+ lastEditedPath = absPath;
1257
+ }),
1140
1258
  };
1141
1259
  };
1142
1260
  const buildLspLensSymbolOutput = async (filePathRaw) => {
@@ -1178,8 +1296,8 @@ export async function createSession(opts) {
1178
1296
  if (!planSteps.length)
1179
1297
  return ['No plan steps to execute.'];
1180
1298
  const toExec = index != null
1181
- ? planSteps.filter(s => s.index === index && s.blocked && !s.executed)
1182
- : planSteps.filter(s => s.blocked && !s.executed);
1299
+ ? planSteps.filter((s) => s.index === index && s.blocked && !s.executed)
1300
+ : planSteps.filter((s) => s.blocked && !s.executed);
1183
1301
  if (!toExec.length)
1184
1302
  return ['No pending blocked steps to execute.'];
1185
1303
  const ctx = buildToolCtx();
@@ -1271,9 +1389,7 @@ export async function createSession(opts) {
1271
1389
  const toolCalls = m.tool_calls;
1272
1390
  if (toolCalls?.length) {
1273
1391
  for (const tc of toolCalls) {
1274
- const args = typeof tc.function?.arguments === 'string'
1275
- ? tc.function.arguments.slice(0, 200)
1276
- : '';
1392
+ const args = typeof tc.function?.arguments === 'string' ? tc.function.arguments.slice(0, 200) : '';
1277
1393
  parts.push(`[tool_call: ${tc.function?.name}(${args})]`);
1278
1394
  }
1279
1395
  }
@@ -1325,16 +1441,16 @@ export async function createSession(opts) {
1325
1441
  const hits = await vault.search(query, 4);
1326
1442
  if (!hits.length)
1327
1443
  return;
1328
- const lines = hits.map((r) => `${r.updatedAt} ${r.kind} ${r.key ?? r.tool ?? r.id} ${String(r.value ?? r.snippet ?? '').replace(/\s+/g, ' ').slice(0, 180)}`);
1444
+ const lines = hits.map((r) => `${r.updatedAt} ${r.kind} ${r.key ?? r.tool ?? r.id} ${String(r.value ?? r.snippet ?? '')
1445
+ .replace(/\s+/g, ' ')
1446
+ .slice(0, 180)}`);
1329
1447
  if (!lines.length)
1330
1448
  return;
1331
1449
  lastVaultInjectionQuery = query;
1332
- const vaultContextHeader = vaultMode === 'passive'
1333
- ? '[Trifecta Vault (passive)]'
1334
- : '[Vault context after compaction]';
1450
+ const vaultContextHeader = vaultMode === 'passive' ? '[Trifecta Vault (passive)]' : '[Vault context after compaction]';
1335
1451
  messages.push({
1336
1452
  role: 'user',
1337
- content: `${vaultContextHeader} Relevant entries for "${query}":\n${lines.join('\n')}`
1453
+ content: `${vaultContextHeader} Relevant entries for "${query}":\n${lines.join('\n')}`,
1338
1454
  });
1339
1455
  };
1340
1456
  let compactionLockTail = Promise.resolve();
@@ -1402,9 +1518,11 @@ export async function createSession(opts) {
1402
1518
  }
1403
1519
  };
1404
1520
  const compactHistory = async (opts) => {
1405
- const reason = opts?.reason
1406
- ?? (opts?.hard ? 'manual hard compaction'
1407
- : opts?.force ? 'manual force compaction'
1521
+ const reason = opts?.reason ??
1522
+ (opts?.hard
1523
+ ? 'manual hard compaction'
1524
+ : opts?.force
1525
+ ? 'manual force compaction'
1408
1526
  : 'manual compaction');
1409
1527
  return await runCompactionWithLock(reason, async () => {
1410
1528
  const beforeMessages = messages.length;
@@ -1430,8 +1548,12 @@ export async function createSession(opts) {
1430
1548
  let dropped = messages.filter((m) => !compactedByRefs.has(m));
1431
1549
  if (opts?.topic) {
1432
1550
  const topic = opts.topic.toLowerCase();
1433
- dropped = dropped.filter((m) => !userContentToText(m.content ?? '').toLowerCase().includes(topic));
1434
- const keepFromTopic = messages.filter((m) => userContentToText(m.content ?? '').toLowerCase().includes(topic));
1551
+ dropped = dropped.filter((m) => !userContentToText(m.content ?? '')
1552
+ .toLowerCase()
1553
+ .includes(topic));
1554
+ const keepFromTopic = messages.filter((m) => userContentToText(m.content ?? '')
1555
+ .toLowerCase()
1556
+ .includes(topic));
1435
1557
  compacted = [...compacted, ...keepFromTopic.filter((m) => !compactedByRefs.has(m))];
1436
1558
  }
1437
1559
  const archivedToolMessages = dropped.filter((m) => m.role === 'tool').length;
@@ -1447,7 +1569,10 @@ export async function createSession(opts) {
1447
1569
  const m = messages[i];
1448
1570
  if (m.role === 'user') {
1449
1571
  const text = userContentToText((m.content ?? '')).trim();
1450
- if (text && !text.startsWith('[Trifecta Vault') && !text.startsWith('[Vault context') && text.length > 20) {
1572
+ if (text &&
1573
+ !text.startsWith('[Trifecta Vault') &&
1574
+ !text.startsWith('[Vault context') &&
1575
+ text.length > 20) {
1451
1576
  userPromptToPreserve = text;
1452
1577
  break;
1453
1578
  }
@@ -1467,7 +1592,10 @@ export async function createSession(opts) {
1467
1592
  // Update current context token count after compaction
1468
1593
  currentContextTokens = estimateTokensFromMessages(compacted);
1469
1594
  if (dropped.length) {
1470
- messages.push({ role: 'system', content: buildCompactionSystemNote('manual', dropped.length) });
1595
+ messages.push({
1596
+ role: 'system',
1597
+ content: buildCompactionSystemNote('manual', dropped.length),
1598
+ });
1471
1599
  await injectVaultContext().catch(() => { });
1472
1600
  if (opts?.reason || opts?.force) {
1473
1601
  injectCompactionReminder(opts?.reason ?? 'history compaction');
@@ -1495,7 +1623,10 @@ export async function createSession(opts) {
1495
1623
  let lastTurnMetrics;
1496
1624
  let lastServerHealth;
1497
1625
  let lastToolLoopStats = {
1498
- totalHistory: 0, signatures: [], outcomes: [], telemetry: {
1626
+ totalHistory: 0,
1627
+ signatures: [],
1628
+ outcomes: [],
1629
+ telemetry: {
1499
1630
  callsRegistered: 0,
1500
1631
  dedupedReplays: 0,
1501
1632
  readCacheLookups: 0,
@@ -1505,7 +1636,7 @@ export async function createSession(opts) {
1505
1636
  recoveryRecommended: 0,
1506
1637
  readCacheHitRate: 0,
1507
1638
  dedupeRate: 0,
1508
- }
1639
+ },
1509
1640
  };
1510
1641
  let lastModelsProbeMs = 0;
1511
1642
  const capturesDir = path.join(stateDir(), 'captures');
@@ -1667,7 +1798,8 @@ export async function createSession(opts) {
1667
1798
  modelsList = normalizeModelsResponse(await client.models());
1668
1799
  const chosen = modelName?.trim()
1669
1800
  ? modelName.trim()
1670
- : (modelsList.data.find((m) => m.id === model)?.id ?? await autoPickModel(client, modelsList));
1801
+ : (modelsList.data.find((m) => m.id === model)?.id ??
1802
+ (await autoPickModel(client, modelsList)));
1671
1803
  setModel(chosen);
1672
1804
  };
1673
1805
  const captureOn = async (filePath) => {
@@ -1685,9 +1817,7 @@ export async function createSession(opts) {
1685
1817
  if (!lastCaptureRecord) {
1686
1818
  throw new Error('No captured request/response pair is available yet.');
1687
1819
  }
1688
- const target = filePath?.trim()
1689
- ? path.resolve(filePath)
1690
- : (capturePath || defaultCapturePath());
1820
+ const target = filePath?.trim() ? path.resolve(filePath) : capturePath || defaultCapturePath();
1691
1821
  await appendCaptureRecord(lastCaptureRecord, target);
1692
1822
  return target;
1693
1823
  };
@@ -1741,7 +1871,7 @@ export async function createSession(opts) {
1741
1871
  const idx = Math.max(0, Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * q)));
1742
1872
  return sorted[idx];
1743
1873
  };
1744
- const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : undefined);
1874
+ const avg = (arr) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : undefined;
1745
1875
  return {
1746
1876
  turns: turnDurationsMs.length,
1747
1877
  totalTokens,
@@ -1810,7 +1940,7 @@ export async function createSession(opts) {
1810
1940
  setModel(nextModel);
1811
1941
  messages.push({
1812
1942
  role: 'system',
1813
- content: '[system] Model changed mid-session. Previous context may not transfer perfectly.'
1943
+ content: '[system] Model changed mid-session. Previous context may not transfer perfectly.',
1814
1944
  });
1815
1945
  console.warn(`[model] Server model changed: ${previousModel} → ${nextModel} - switching harness to ${harness.id}`);
1816
1946
  };
@@ -1835,7 +1965,7 @@ export async function createSession(opts) {
1835
1965
  sessionMetaPending = null;
1836
1966
  }
1837
1967
  messages.push({ role: 'user', content: userContent });
1838
- const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
1968
+ const hookObj = typeof hooks === 'function' ? { onToken: hooks } : (hooks ?? {});
1839
1969
  let turns = 0;
1840
1970
  let toolCalls = 0;
1841
1971
  const askId = `ask-${timestampedId()}`;
@@ -1858,7 +1988,9 @@ export async function createSession(opts) {
1858
1988
  }
1859
1989
  };
1860
1990
  const isReadOnlyToolDynamic = (toolName) => {
1861
- return isReadOnlyTool(toolName) || LSP_TOOL_NAME_SET.has(toolName) || Boolean(mcpManager?.isToolReadOnly(toolName));
1991
+ return (isReadOnlyTool(toolName) ||
1992
+ LSP_TOOL_NAME_SET.has(toolName) ||
1993
+ Boolean(mcpManager?.isToolReadOnly(toolName)));
1862
1994
  };
1863
1995
  const emitToolResult = async (result) => {
1864
1996
  await hookObj.onToolResult?.(result);
@@ -1886,16 +2018,15 @@ export async function createSession(opts) {
1886
2018
  // Cap action list to prevent vault bloat on long sessions
1887
2019
  const MAX_SUMMARY_ACTIONS = 30;
1888
2020
  const cappedActions = actions.length > MAX_SUMMARY_ACTIONS
1889
- ? [...actions.slice(0, MAX_SUMMARY_ACTIONS), `... and ${actions.length - MAX_SUMMARY_ACTIONS} more`]
2021
+ ? [
2022
+ ...actions.slice(0, MAX_SUMMARY_ACTIONS),
2023
+ `... and ${actions.length - MAX_SUMMARY_ACTIONS} more`,
2024
+ ]
1890
2025
  : actions;
1891
2026
  const userPrompt = lastAskInstructionText || '(unknown)';
1892
- const userPromptSnippet = userPrompt.length > 120
1893
- ? userPrompt.slice(0, 120) + '…'
1894
- : userPrompt;
1895
- const resultSnippet = finalText.length > 200
1896
- ? finalText.slice(0, 200) + '…'
1897
- : finalText;
1898
- const summary = `User asked: ${userPromptSnippet}\nActions (${actions.length} tool calls, ${turns} turns):\n${cappedActions.map(a => `- ${a}`).join('\n')}\nResult: ${resultSnippet}`;
2027
+ const userPromptSnippet = userPrompt.length > 120 ? userPrompt.slice(0, 120) + '…' : userPrompt;
2028
+ const resultSnippet = finalText.length > 200 ? finalText.slice(0, 200) + '…' : finalText;
2029
+ const summary = `User asked: ${userPromptSnippet}\nActions (${actions.length} tool calls, ${turns} turns):\n${cappedActions.map((a) => `- ${a}`).join('\n')}\nResult: ${resultSnippet}`;
1899
2030
  await vault.upsertNote(`turn_summary_${askId}`, summary, 'system');
1900
2031
  }
1901
2032
  }
@@ -1913,7 +2044,9 @@ export async function createSession(opts) {
1913
2044
  const reviewKeys = reviewArtifactKeys(projectDir);
1914
2045
  const retrievalRequested = looksLikeReviewRetrievalRequest(rawInstructionText);
1915
2046
  const shouldPersistReviewArtifact = looksLikeCodeReviewRequest(rawInstructionText) && !retrievalRequested;
1916
- if (!retrievalRequested && cfg.initial_connection_check !== false && !initialConnectionProbeDone) {
2047
+ if (!retrievalRequested &&
2048
+ cfg.initial_connection_check !== false &&
2049
+ !initialConnectionProbeDone) {
1917
2050
  if (typeof client.probeConnection === 'function') {
1918
2051
  await client.probeConnection();
1919
2052
  initialConnectionProbeDone = true;
@@ -1924,9 +2057,7 @@ export async function createSession(opts) {
1924
2057
  ? await vault.getLatestByKey(reviewKeys.latestKey, 'system').catch(() => null)
1925
2058
  : null;
1926
2059
  const parsedArtifact = latest?.value ? parseReviewArtifact(latest.value) : null;
1927
- const artifact = parsedArtifact && parsedArtifact.projectId === reviewKeys.projectId
1928
- ? parsedArtifact
1929
- : null;
2060
+ const artifact = parsedArtifact && parsedArtifact.projectId === reviewKeys.projectId ? parsedArtifact : null;
1930
2061
  if (artifact?.content?.trim()) {
1931
2062
  const stale = reviewArtifactStaleReason(artifact, projectDir);
1932
2063
  const stalePolicy = parseReviewArtifactStalePolicy(cfg?.trifecta?.vault?.stale_policy);
@@ -1943,9 +2074,7 @@ export async function createSession(opts) {
1943
2074
  });
1944
2075
  return await finalizeAsk(blocked);
1945
2076
  }
1946
- const text = stale
1947
- ? `${artifact.content}\n\n[artifact note] ${stale}`
1948
- : artifact.content;
2077
+ const text = stale ? `${artifact.content}\n\n[artifact note] ${stale}` : artifact.content;
1949
2078
  messages.push({ role: 'assistant', content: text });
1950
2079
  hookObj.onToken?.(text);
1951
2080
  await emitTurnEnd({
@@ -2046,7 +2175,7 @@ export async function createSession(opts) {
2046
2175
  // pressure can replay the cached result instead of re-executing.
2047
2176
  const lastExecResultBySig = new Map();
2048
2177
  // Cache successful read_file/read_files/list_dir results by signature + mtime for invalidation.
2049
- const readFileCacheBySig = new Map();
2178
+ const _readFileCacheBySig = new Map();
2050
2179
  const READ_FILE_CACHE_TOOLS = new Set(['read_file', 'read_files', 'list_dir']);
2051
2180
  const toolLoopGuard = new ToolLoopGuard({
2052
2181
  enabled: cfg.tool_loop_detection?.enabled,
@@ -2079,6 +2208,8 @@ export async function createSession(opts) {
2079
2208
  let toollessRecoveryUsed = false;
2080
2209
  // Prevent repeating the same "stop rerunning" reminder every turn.
2081
2210
  const readOnlyExecHintedSigs = new Set();
2211
+ // Tool loop recovery: poisoned results and selective tool suppression.
2212
+ const suppressedTools = new Set();
2082
2213
  // Keep a lightweight breadcrumb for diagnostics on partial failures.
2083
2214
  let lastSuccessfulTestRun = null;
2084
2215
  // One-time nudge to prevent post-success churn after green test runs.
@@ -2106,7 +2237,11 @@ export async function createSession(opts) {
2106
2237
  const compactToolMessageForHistory = async (toolCallId, rawContent) => {
2107
2238
  const toolName = toolNameByCallId.get(toolCallId) ?? 'tool';
2108
2239
  const toolArgs = toolArgsByCallId.get(toolCallId) ?? {};
2109
- const rawMsg = { role: 'tool', tool_call_id: toolCallId, content: rawContent };
2240
+ const rawMsg = {
2241
+ role: 'tool',
2242
+ tool_call_id: toolCallId,
2243
+ content: rawContent,
2244
+ };
2110
2245
  // Persist full-fidelity output immediately so live context can stay small.
2111
2246
  if (vault && typeof vault.archiveToolResult === 'function') {
2112
2247
  try {
@@ -2120,7 +2255,9 @@ export async function createSession(opts) {
2120
2255
  if (lens) {
2121
2256
  try {
2122
2257
  const lensCompact = await lens.summarizeToolOutput(rawContent, toolName, typeof toolArgs.path === 'string' ? String(toolArgs.path) : undefined);
2123
- if (typeof lensCompact === 'string' && lensCompact.length && lensCompact.length < compact.length) {
2258
+ if (typeof lensCompact === 'string' &&
2259
+ lensCompact.length &&
2260
+ lensCompact.length < compact.length) {
2124
2261
  compact = lensCompact;
2125
2262
  }
2126
2263
  }
@@ -2205,7 +2342,10 @@ export async function createSession(opts) {
2205
2342
  if (m.role === 'user') {
2206
2343
  const text = userContentToText((m.content ?? '')).trim();
2207
2344
  // Skip vault injection messages and short prompts
2208
- if (text && !text.startsWith('[Trifecta Vault') && !text.startsWith('[Vault context') && text.length > 20) {
2345
+ if (text &&
2346
+ !text.startsWith('[Trifecta Vault') &&
2347
+ !text.startsWith('[Vault context') &&
2348
+ text.length > 20) {
2209
2349
  userPromptToPreserve = text;
2210
2350
  break;
2211
2351
  }
@@ -2234,7 +2374,10 @@ export async function createSession(opts) {
2234
2374
  const resp = await client.chat({
2235
2375
  model,
2236
2376
  messages: [
2237
- { role: 'system', content: 'Summarize this agent session progress concisely. List: files read, key findings, decisions made, current approach. Be terse.' },
2377
+ {
2378
+ role: 'system',
2379
+ content: 'Summarize this agent session progress concisely. List: files read, key findings, decisions made, current approach. Be terse.',
2380
+ },
2238
2381
  { role: 'user', content: summaryContent },
2239
2382
  ],
2240
2383
  max_tokens: summaryMaxTokens,
@@ -2250,15 +2393,24 @@ export async function createSession(opts) {
2250
2393
  });
2251
2394
  }
2252
2395
  else {
2253
- messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2396
+ messages.push({
2397
+ role: 'system',
2398
+ content: buildCompactionSystemNote('auto', dropped.length),
2399
+ });
2254
2400
  }
2255
2401
  }
2256
2402
  catch {
2257
- messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2403
+ messages.push({
2404
+ role: 'system',
2405
+ content: buildCompactionSystemNote('auto', dropped.length),
2406
+ });
2258
2407
  }
2259
2408
  }
2260
2409
  else {
2261
- messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2410
+ messages.push({
2411
+ role: 'system',
2412
+ content: buildCompactionSystemNote('auto', dropped.length),
2413
+ });
2262
2414
  }
2263
2415
  }
2264
2416
  // Update token count AFTER injections so downstream reads are accurate
@@ -2268,9 +2420,15 @@ export async function createSession(opts) {
2268
2420
  // Emit compaction event for callers (e.g. Anton controller → Discord)
2269
2421
  if (dropped.length) {
2270
2422
  try {
2271
- await hookObj.onCompaction?.({ droppedMessages: dropped.length, freedTokens, summaryUsed });
2423
+ await hookObj.onCompaction?.({
2424
+ droppedMessages: dropped.length,
2425
+ freedTokens,
2426
+ summaryUsed,
2427
+ });
2428
+ }
2429
+ catch {
2430
+ /* best effort */
2272
2431
  }
2273
- catch { /* best effort */ }
2274
2432
  console.error(`[compaction] auto: dropped=${dropped.length} msgs, freed=~${freedTokens} tokens, summary=${summaryUsed}, remaining=${messages.length} msgs (~${currentContextTokens} tokens)`);
2275
2433
  }
2276
2434
  return {
@@ -2306,8 +2464,10 @@ export async function createSession(opts) {
2306
2464
  let resp;
2307
2465
  try {
2308
2466
  try {
2309
- const toolsForTurn = (cfg.no_tools || forceToollessRecoveryTurn) ? [] : getToolsSchema();
2310
- const toolChoiceForTurn = (cfg.no_tools || forceToollessRecoveryTurn) ? 'none' : 'auto';
2467
+ const toolsForTurn = (cfg.no_tools || forceToollessRecoveryTurn)
2468
+ ? []
2469
+ : getToolsSchema().filter(t => !suppressedTools.has(t.function.name));
2470
+ const toolChoiceForTurn = cfg.no_tools || forceToollessRecoveryTurn ? 'none' : 'auto';
2311
2471
  resp = await client.chatStream({
2312
2472
  model,
2313
2473
  messages,
@@ -2326,7 +2486,8 @@ export async function createSession(opts) {
2326
2486
  overflowCompactionAttempts = 0;
2327
2487
  }
2328
2488
  catch (e) {
2329
- if (isContextWindowExceededError(e) && overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS) {
2489
+ if (isContextWindowExceededError(e) &&
2490
+ overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS) {
2330
2491
  overflowCompactionAttempts++;
2331
2492
  const useHardCompaction = overflowCompactionAttempts > 1;
2332
2493
  const compacted = await compactHistory({
@@ -2364,9 +2525,7 @@ export async function createSession(opts) {
2364
2525
  ? promptTokensTurn / (ttftMs / 1000)
2365
2526
  : undefined;
2366
2527
  const genWindowMs = Math.max(1, ttcMs - (ttftMs ?? 0));
2367
- const tgTps = completionTokensTurn > 0
2368
- ? completionTokensTurn / (genWindowMs / 1000)
2369
- : undefined;
2528
+ const tgTps = completionTokensTurn > 0 ? completionTokensTurn / (genWindowMs / 1000) : undefined;
2370
2529
  if (ttcMs > 0)
2371
2530
  turnDurationsMs.push(ttcMs);
2372
2531
  if (ttftMs != null && ttftMs > 0)
@@ -2413,7 +2572,9 @@ export async function createSession(opts) {
2413
2572
  // Conditionally strip thinking blocks based on harness config (§4i).
2414
2573
  // Non-reasoning models (thinking.strip === false) never emit <think> blocks,
2415
2574
  // so stripping is a no-op — but we skip the regex work entirely.
2416
- const st = harness.thinking.strip ? stripThinking(content) : { visible: content, thinking: '' };
2575
+ const st = harness.thinking.strip
2576
+ ? stripThinking(content)
2577
+ : { visible: content, thinking: '' };
2417
2578
  // Strip XML tool-call tag fragments that leak into visible narration
2418
2579
  // when llama-server partially parses Qwen/Hermes XML tool calls.
2419
2580
  const visible = st.visible
@@ -2431,7 +2592,9 @@ export async function createSession(opts) {
2431
2592
  // For models with unreliable tool_calls arrays, validate entries and
2432
2593
  // fall through to content parsing if they look malformed (§4i).
2433
2594
  if (toolCallsArr?.length && !harness.toolCalls.reliableToolCallsArray) {
2434
- const hasValid = toolCallsArr.some(tc => tc.function?.name && typeof tc.function.name === 'string' && tc.function.name.length > 0);
2595
+ const hasValid = toolCallsArr.some((tc) => tc.function?.name &&
2596
+ typeof tc.function.name === 'string' &&
2597
+ tc.function.name.length > 0);
2435
2598
  if (!hasValid) {
2436
2599
  if (cfg.verbose) {
2437
2600
  console.warn(`[harness] tool_calls array present but no valid entries (reliableToolCallsArray=false), trying content fallback`);
@@ -2519,7 +2682,7 @@ export async function createSession(opts) {
2519
2682
  const deduped = [];
2520
2683
  for (const [, group] of byName) {
2521
2684
  if (group.length > 1) {
2522
- const maxArgs = Math.max(...group.map(g => g.argCount));
2685
+ const maxArgs = Math.max(...group.map((g) => g.argCount));
2523
2686
  // Drop entries with strictly fewer args than the richest (ghost duplicates).
2524
2687
  // Keep ALL entries that have the max arg count (genuine parallel calls).
2525
2688
  for (const g of group) {
@@ -2552,15 +2715,22 @@ export async function createSession(opts) {
2552
2715
  const compactAssistantToolCallText = assistantToolCallText.length > 900
2553
2716
  ? `${assistantToolCallText.slice(0, 900)}\n[history-compacted: assistant narration truncated before tool execution]`
2554
2717
  : assistantToolCallText;
2555
- messages.push({ role: 'assistant', content: compactAssistantToolCallText, tool_calls: originalToolCallsArr });
2718
+ messages.push({
2719
+ role: 'assistant',
2720
+ content: compactAssistantToolCallText,
2721
+ tool_calls: originalToolCallsArr,
2722
+ });
2556
2723
  // sigCounts is scoped to the entire ask() run (see above)
2557
2724
  // Bridge ConfirmationProvider → legacy confirm callback for tools.
2558
2725
  // If a ConfirmationProvider is given, wrap it; otherwise fall back to raw callback.
2559
2726
  // The bridge accepts an optional context object for rich confirm data.
2560
2727
  const confirmBridge = opts.confirmProvider
2561
2728
  ? async (prompt, bridgeCtx) => opts.confirmProvider.confirm({
2562
- tool: bridgeCtx?.tool ?? '', args: bridgeCtx?.args ?? {}, summary: prompt,
2563
- diff: bridgeCtx?.diff, mode: cfg.approval_mode,
2729
+ tool: bridgeCtx?.tool ?? '',
2730
+ args: bridgeCtx?.args ?? {},
2731
+ summary: prompt,
2732
+ diff: bridgeCtx?.diff,
2733
+ mode: cfg.approval_mode,
2564
2734
  })
2565
2735
  : opts.confirm;
2566
2736
  const ctx = buildToolCtx({
@@ -2569,6 +2739,7 @@ export async function createSession(opts) {
2569
2739
  onMutation: (absPath) => {
2570
2740
  lastEditedPath = absPath;
2571
2741
  mutationVersion++;
2742
+ suppressedTools.clear(); // file changed, re-enable all tools
2572
2743
  },
2573
2744
  });
2574
2745
  // Tool-call argument parsing and validation logic
@@ -2611,6 +2782,8 @@ export async function createSession(opts) {
2611
2782
  const replayExecSigs = new Set();
2612
2783
  // Repeated read_file/read_files/list_dir calls can be served from cache.
2613
2784
  const repeatedReadFileSigs = new Set();
2785
+ // Poisoned tool sigs: at consec >= 3, don't execute — return error instead.
2786
+ const poisonedToolSigs = new Set();
2614
2787
  let shouldForceToollessRecovery = false;
2615
2788
  const criticalLoopSigs = new Set();
2616
2789
  for (const tc of toolCallsArr) {
@@ -2689,9 +2862,10 @@ export async function createSession(opts) {
2689
2862
  }
2690
2863
  if (count >= loopThreshold) {
2691
2864
  const sigArgs = sigMetaBySig.get(sig)?.args ?? {};
2692
- const command = typeof sigArgs?.command === 'string' ? String(sigArgs.command) : '';
2693
- const canReuseReadOnlyObservation = looksLikeReadOnlyExecCommand(command) &&
2694
- execObservationCacheBySig.has(sig);
2865
+ const command = typeof sigArgs?.command === 'string'
2866
+ ? String(sigArgs.command)
2867
+ : '';
2868
+ const canReuseReadOnlyObservation = looksLikeReadOnlyExecCommand(command) && execObservationCacheBySig.has(sig);
2695
2869
  if (canReuseReadOnlyObservation) {
2696
2870
  repeatedReadOnlyExecSigs.add(sig);
2697
2871
  if (!readOnlyExecHintedSigs.has(sig)) {
@@ -2701,7 +2875,9 @@ export async function createSession(opts) {
2701
2875
  continue;
2702
2876
  }
2703
2877
  const argsPreviewRaw = JSON.stringify(sigArgs);
2704
- const argsPreview = argsPreviewRaw.length > 220 ? argsPreviewRaw.slice(0, 220) + '…' : argsPreviewRaw;
2878
+ const argsPreview = argsPreviewRaw.length > 220
2879
+ ? argsPreviewRaw.slice(0, 220) + '…'
2880
+ : argsPreviewRaw;
2705
2881
  throw new Error(`tool ${toolName}: identical call repeated ${loopThreshold}x across turns; breaking loop. ` +
2706
2882
  `args=${argsPreview}`);
2707
2883
  }
@@ -2743,21 +2919,17 @@ export async function createSession(opts) {
2743
2919
  }
2744
2920
  }
2745
2921
  }
2746
- // At 2x, serve from cache if available AND inject final warning
2747
- if (consec >= 2 && isReadFileTool) {
2748
- if (consec === 4) {
2749
- let resourceType = 'resource';
2750
- if (toolName === 'read_file')
2751
- resourceType = 'file';
2752
- else if (toolName === 'read_files')
2753
- resourceType = 'files';
2754
- else if (toolName === 'list_dir')
2755
- resourceType = 'directory';
2756
- messages.push({
2757
- role: 'system',
2758
- content: `CRITICAL: ${resourceType} unchanged. Move on NOW.`,
2759
- });
2922
+ // At consec >= 3: poison the result (don't execute, return error).
2923
+ // At consec >= 4: also suppress the tool from the schema entirely.
2924
+ if (consec >= 3) {
2925
+ poisonedToolSigs.add(sig);
2926
+ if (consec >= 4) {
2927
+ suppressedTools.add(toolName);
2760
2928
  }
2929
+ continue;
2930
+ }
2931
+ // At 2x, serve from cache if available
2932
+ if (consec >= 2 && isReadFileTool) {
2761
2933
  const argsForSig = sigMetaBySig.get(sig)?.args ?? {};
2762
2934
  const replay = await toolLoopGuard.getReadCacheReplay(toolName, argsForSig, ctx.cwd);
2763
2935
  if (replay) {
@@ -2836,7 +3008,9 @@ export async function createSession(opts) {
2836
3008
  throw new ToolError('invalid_args', `tool ${name}: arguments not valid JSON`, false, 'Return a valid JSON object for function.arguments.', { raw: rawArgs.slice(0, 200) });
2837
3009
  }
2838
3010
  if (args == null || typeof args !== 'object' || Array.isArray(args)) {
2839
- throw new ValidationError([{ field: 'arguments', message: 'must be a JSON object', value: args }]);
3011
+ throw new ValidationError([
3012
+ { field: 'arguments', message: 'must be a JSON object', value: args },
3013
+ ]);
2840
3014
  }
2841
3015
  const builtInFn = tools[name];
2842
3016
  const isLspTool = LSP_TOOL_NAME_SET.has(name);
@@ -2854,7 +3028,11 @@ export async function createSession(opts) {
2854
3028
  if (builtInFn || isSpawnTask) {
2855
3029
  const missing = getMissingRequiredParams(name, args);
2856
3030
  if (missing.length) {
2857
- throw new ValidationError(missing.map((m) => ({ field: m, message: 'required parameter is missing', value: undefined })));
3031
+ throw new ValidationError(missing.map((m) => ({
3032
+ field: m,
3033
+ message: 'required parameter is missing',
3034
+ value: undefined,
3035
+ })));
2858
3036
  }
2859
3037
  const argIssues = getArgValidationIssues(name, args);
2860
3038
  if (argIssues.length) {
@@ -2873,7 +3051,9 @@ export async function createSession(opts) {
2873
3051
  }
2874
3052
  }
2875
3053
  if (FILE_MUTATION_TOOL_SET.has(name) && typeof args.path === 'string') {
2876
- const absPath = args.path.startsWith('/') ? args.path : path.resolve(projectDir, args.path);
3054
+ const absPath = args.path.startsWith('/')
3055
+ ? args.path
3056
+ : path.resolve(projectDir, args.path);
2877
3057
  // ── Pre-dispatch: block edits to files in a mutation spiral ──
2878
3058
  if (fileMutationBlocked.has(absPath)) {
2879
3059
  const basename = path.basename(absPath);
@@ -2895,12 +3075,23 @@ export async function createSession(opts) {
2895
3075
  // Fix 1: Hard cumulative budget — refuse reads past hard cap
2896
3076
  if (cumulativeReadOnlyCalls > READ_BUDGET_HARD) {
2897
3077
  await emitToolCall({ id: callId, name, args });
2898
- await emitToolResult({ id: callId, name, success: false, summary: 'read budget exhausted', result: '' });
2899
- return { id: callId, content: `STOP: Read budget exhausted (${cumulativeReadOnlyCalls}/${READ_BUDGET_HARD} calls). Do NOT read more files. Use search_files or exec: grep -rn "pattern" path/ to find what you need.` };
3078
+ await emitToolResult({
3079
+ id: callId,
3080
+ name,
3081
+ success: false,
3082
+ summary: 'read budget exhausted',
3083
+ result: '',
3084
+ });
3085
+ return {
3086
+ id: callId,
3087
+ content: `STOP: Read budget exhausted (${cumulativeReadOnlyCalls}/${READ_BUDGET_HARD} calls). Do NOT read more files. Use search_files or exec: grep -rn "pattern" path/ to find what you need.`,
3088
+ };
2900
3089
  }
2901
3090
  // Fix 2: Directory scan detection — counts unique files per dir (re-reads are OK)
2902
3091
  if (filePath) {
2903
- const absFilePath = filePath.startsWith('/') ? filePath : path.resolve(projectDir, filePath);
3092
+ const absFilePath = filePath.startsWith('/')
3093
+ ? filePath
3094
+ : path.resolve(projectDir, filePath);
2904
3095
  const parentDir = path.dirname(absFilePath);
2905
3096
  if (!readDirFiles.has(parentDir))
2906
3097
  readDirFiles.set(parentDir, new Set());
@@ -2911,8 +3102,17 @@ export async function createSession(opts) {
2911
3102
  }
2912
3103
  if (blockedDirs.has(parentDir) && uniqueCount > 8) {
2913
3104
  await emitToolCall({ id: callId, name, args });
2914
- await emitToolResult({ id: callId, name, success: false, summary: 'dir scan blocked', result: '' });
2915
- return { id: callId, content: `STOP: Directory scan detected — you've read ${uniqueCount} unique files from ${parentDir}/. Use search_files(pattern, '${parentDir}') or exec: grep -rn "pattern" ${parentDir}/ instead of reading files individually.` };
3105
+ await emitToolResult({
3106
+ id: callId,
3107
+ name,
3108
+ success: false,
3109
+ summary: 'dir scan blocked',
3110
+ result: '',
3111
+ });
3112
+ return {
3113
+ id: callId,
3114
+ content: `STOP: Directory scan detected — you've read ${uniqueCount} unique files from ${parentDir}/. Use search_files(pattern, '${parentDir}') or exec: grep -rn "pattern" ${parentDir}/ instead of reading files individually.`,
3115
+ };
2916
3116
  }
2917
3117
  }
2918
3118
  // Fix 3: Same-search-term detection
@@ -2923,8 +3123,17 @@ export async function createSession(opts) {
2923
3123
  searchTermFiles.get(key).add(filePath);
2924
3124
  if (searchTermFiles.get(key).size >= 3) {
2925
3125
  await emitToolCall({ id: callId, name, args });
2926
- await emitToolResult({ id: callId, name, success: false, summary: 'use search_files', result: '' });
2927
- return { id: callId, content: `STOP: You've searched ${searchTermFiles.get(key).size} files for "${searchTerm}" one at a time. This is what search_files does in one call. Use: search_files(pattern="${searchTerm}", path=".") or exec: grep -rn "${searchTerm}" .` };
3126
+ await emitToolResult({
3127
+ id: callId,
3128
+ name,
3129
+ success: false,
3130
+ summary: 'use search_files',
3131
+ result: '',
3132
+ });
3133
+ return {
3134
+ id: callId,
3135
+ content: `STOP: You've searched ${searchTermFiles.get(key).size} files for "${searchTerm}" one at a time. This is what search_files does in one call. Use: search_files(pattern="${searchTerm}", path=".") or exec: grep -rn "${searchTerm}" .`,
3136
+ };
2928
3137
  }
2929
3138
  }
2930
3139
  }
@@ -2943,22 +3152,40 @@ export async function createSession(opts) {
2943
3152
  planSteps.push(step);
2944
3153
  const blockedMsg = `[blocked: approval_mode=plan] Would ${summary}`;
2945
3154
  // Notify via confirmProvider.showBlocked if available
2946
- opts.confirmProvider?.showBlocked?.({ tool: name, args, reason: `plan mode: ${summary}` });
3155
+ opts.confirmProvider?.showBlocked?.({
3156
+ tool: name,
3157
+ args,
3158
+ reason: `plan mode: ${summary}`,
3159
+ });
2947
3160
  // Hook: onToolCall + onToolResult for plan-blocked actions
2948
3161
  await emitToolCall({ id: callId, name, args });
2949
- await emitToolResult({ id: callId, name, success: true, summary: `⏸ ${summary} (blocked)`, result: blockedMsg });
3162
+ await emitToolResult({
3163
+ id: callId,
3164
+ name,
3165
+ success: true,
3166
+ summary: `⏸ ${summary} (blocked)`,
3167
+ result: blockedMsg,
3168
+ });
2950
3169
  return { id: callId, content: blockedMsg };
2951
3170
  }
2952
3171
  // Hook: onToolCall (Phase 8.5)
2953
3172
  await emitToolCall({ id: callId, name, args });
2954
3173
  if (cfg.step_mode) {
2955
3174
  const stepPrompt = `Step mode: execute ${name}(${JSON.stringify(args).slice(0, 200)}) ? [Y/n]`;
2956
- const ok = confirmBridge ? await confirmBridge(stepPrompt, { tool: name, args }) : true;
3175
+ const ok = confirmBridge
3176
+ ? await confirmBridge(stepPrompt, { tool: name, args })
3177
+ : true;
2957
3178
  if (!ok) {
2958
3179
  return { id: callId, content: '[skipped by user: step mode]' };
2959
3180
  }
2960
3181
  }
2961
3182
  const sig = toolLoopGuard.computeSignature(name, args && typeof args === 'object' && !Array.isArray(args) ? args : {});
3183
+ // Poisoned tool call: don't execute, return error-like result.
3184
+ if (poisonedToolSigs.has(sig)) {
3185
+ const consec = consecutiveCounts.get(sig) ?? 3;
3186
+ const poisonMsg = `Error: This exact ${name} call has been repeated ${consec} times with identical arguments and results. The tool is temporarily disabled. Use the information you already have, or try a different approach.`;
3187
+ return { id: callId, content: poisonMsg };
3188
+ }
2962
3189
  let content = '';
2963
3190
  let reusedCachedReadOnlyExec = false;
2964
3191
  let reusedCachedReadTool = false;
@@ -2997,7 +3224,9 @@ export async function createSession(opts) {
2997
3224
  };
2998
3225
  const value = await builtInFn(callCtx, args);
2999
3226
  content = typeof value === 'string' ? value : JSON.stringify(value);
3000
- if (READ_FILE_CACHE_TOOLS.has(name) && typeof content === 'string' && !content.startsWith('ERROR:')) {
3227
+ if (READ_FILE_CACHE_TOOLS.has(name) &&
3228
+ typeof content === 'string' &&
3229
+ !content.startsWith('ERROR:')) {
3001
3230
  const baseCwd = typeof args?.cwd === 'string' ? String(args.cwd) : ctx.cwd;
3002
3231
  await toolLoopGuard.storeReadCache(name, args, baseCwd, content);
3003
3232
  }
@@ -3040,7 +3269,9 @@ export async function createSession(opts) {
3040
3269
  const mcpReadOnly = isReadOnlyToolDynamic(name);
3041
3270
  if (!cfg.step_mode && !ctx.noConfirm && !mcpReadOnly) {
3042
3271
  const prompt = `Execute MCP tool '${name}'? [Y/n]`;
3043
- const ok = confirmBridge ? await confirmBridge(prompt, { tool: name, args }) : true;
3272
+ const ok = confirmBridge
3273
+ ? await confirmBridge(prompt, { tool: name, args })
3274
+ : true;
3044
3275
  if (!ok) {
3045
3276
  return { id: callId, content: '[skipped by user: approval]' };
3046
3277
  }
@@ -3064,7 +3295,13 @@ export async function createSession(opts) {
3064
3295
  let summary = reusedCachedReadOnlyExec
3065
3296
  ? 'cached read-only exec observation (unchanged)'
3066
3297
  : toolResultSummary(name, args, content, true);
3067
- const resultEvent = { id: callId, name, success: true, summary, result: content };
3298
+ const resultEvent = {
3299
+ id: callId,
3300
+ name,
3301
+ success: true,
3302
+ summary,
3303
+ result: content,
3304
+ };
3068
3305
  // Phase 7: populate rich display fields
3069
3306
  if (name === 'exec') {
3070
3307
  try {
@@ -3114,14 +3351,18 @@ export async function createSession(opts) {
3114
3351
  const mutatedPath = typeof args.path === 'string' ? args.path : '';
3115
3352
  if (mutatedPath) {
3116
3353
  try {
3117
- const absPath = mutatedPath.startsWith('/') ? mutatedPath : path.join(projectDir, mutatedPath);
3354
+ const absPath = mutatedPath.startsWith('/')
3355
+ ? mutatedPath
3356
+ : path.join(projectDir, mutatedPath);
3118
3357
  const fileText = await fs.readFile(absPath, 'utf8');
3119
3358
  await lspManager.ensureOpen(absPath, fileText);
3120
3359
  await lspManager.notifyDidSave(absPath, fileText);
3121
3360
  // Small delay so the server can process diagnostics
3122
3361
  await new Promise((r) => setTimeout(r, 200));
3123
3362
  const diags = await lspManager.getDiagnostics(absPath);
3124
- if (diags && !diags.startsWith('No diagnostics') && !diags.startsWith('[lsp] no language')) {
3363
+ if (diags &&
3364
+ !diags.startsWith('No diagnostics') &&
3365
+ !diags.startsWith('[lsp] no language')) {
3125
3366
  content += `\n\n[lsp] Diagnostics after edit:\n${diags}`;
3126
3367
  }
3127
3368
  }
@@ -3139,7 +3380,9 @@ export async function createSession(opts) {
3139
3380
  // Track edits to the same file. If the model keeps editing the same file
3140
3381
  // over and over, it's likely in an edit→break→read→edit corruption spiral.
3141
3382
  if (FILE_MUTATION_TOOL_SET.has(name) && toolSuccess && typeof args.path === 'string') {
3142
- const absPath = args.path.startsWith('/') ? args.path : path.resolve(projectDir, args.path);
3383
+ const absPath = args.path.startsWith('/')
3384
+ ? args.path
3385
+ : path.resolve(projectDir, args.path);
3143
3386
  const basename = path.basename(absPath);
3144
3387
  // write_file = full rewrite. If the model is rewriting the file completely
3145
3388
  // after being warned, give it a fresh chance (reset counter to 1).
@@ -3154,15 +3397,18 @@ export async function createSession(opts) {
3154
3397
  if (count >= FILE_MUTATION_BLOCK_THRESHOLD) {
3155
3398
  // Mark for pre-dispatch blocking on subsequent edits
3156
3399
  fileMutationBlocked.add(absPath);
3157
- content += `\n\n⚠️ BLOCKED: You have edited ${basename} ${count} times. Further edits to this file are now blocked. ` +
3158
- `Restore it with: exec { "command": "git checkout -- ${args.path}" }, then make ONE complete edit.`;
3400
+ content +=
3401
+ `\n\n⚠️ BLOCKED: You have edited ${basename} ${count} times. Further edits to this file are now blocked. ` +
3402
+ `Restore it with: exec { "command": "git checkout -- ${args.path}" }, then make ONE complete edit.`;
3159
3403
  }
3160
- else if (count >= FILE_MUTATION_WARN_THRESHOLD && !fileMutationWarned.has(absPath)) {
3404
+ else if (count >= FILE_MUTATION_WARN_THRESHOLD &&
3405
+ !fileMutationWarned.has(absPath)) {
3161
3406
  fileMutationWarned.add(absPath);
3162
- content += `\n\n⚠️ WARNING: You have edited ${basename} ${count} times. ` +
3163
- 'If the file is broken, STOP making incremental fixes. ' +
3164
- `Restore it with: exec { "command": "git checkout -- ${args.path}" }, ` +
3165
- 'read the original file carefully, then make ONE complete edit. Do NOT continue patching.';
3407
+ content +=
3408
+ `\n\n⚠️ WARNING: You have edited ${basename} ${count} times. ` +
3409
+ 'If the file is broken, STOP making incremental fixes. ' +
3410
+ `Restore it with: exec { "command": "git checkout -- ${args.path}" }, ` +
3411
+ 'read the original file carefully, then make ONE complete edit. Do NOT continue patching.';
3166
3412
  }
3167
3413
  }
3168
3414
  }
@@ -3203,9 +3449,9 @@ export async function createSession(opts) {
3203
3449
  // Fast-fail repeated blocked command loops with accurate reason labeling.
3204
3450
  // Applies to direct exec attempts and spawn_task delegation attempts.
3205
3451
  if (tc.function.name === 'exec' || tc.function.name === 'spawn_task') {
3206
- const blockedMatch = msg.match(/^exec:\s*blocked\s*\(([^)]+)\)\s*without --no-confirm\/--yolo:\s*(.*)$/i)
3207
- || msg.match(/^(spawn_task):\s*blocked\s*—\s*(.*)$/i)
3208
- || msg.match(/^exec:\s*blocked\s+(background command\b[^.]*)\./i);
3452
+ const blockedMatch = msg.match(/^exec:\s*blocked\s*\(([^)]+)\)\s*without --no-confirm\/--yolo:\s*(.*)$/i) ||
3453
+ msg.match(/^(spawn_task):\s*blocked\s*—\s*(.*)$/i) ||
3454
+ msg.match(/^exec:\s*blocked\s+(background command\b[^.]*)\./i);
3209
3455
  if (blockedMatch) {
3210
3456
  const reason = (blockedMatch[1] || blockedMatch[2] || 'blocked command').trim();
3211
3457
  let parsedArgs = {};
@@ -3217,8 +3463,8 @@ export async function createSession(opts) {
3217
3463
  ? String(parsedArgs?.command ?? '')
3218
3464
  : String(parsedArgs?.task ?? '');
3219
3465
  const normalizedReason = reason.toLowerCase();
3220
- const aggregateByReason = normalizedReason.includes('package install/remove')
3221
- || normalizedReason.includes('background command');
3466
+ const aggregateByReason = normalizedReason.includes('package install/remove') ||
3467
+ normalizedReason.includes('background command');
3222
3468
  const sig = aggregateByReason
3223
3469
  ? `${tc.function.name}|${reason}`
3224
3470
  : `${tc.function.name}|${reason}|${cmd}`;
@@ -3279,7 +3525,7 @@ export async function createSession(opts) {
3279
3525
  const callId = resolveCallId(tc);
3280
3526
  results.push({
3281
3527
  id: callId,
3282
- content: `STOP: Per-turn read limit (${READ_ONLY_PER_TURN_CAP}). Use search_files or exec with grep instead of reading files one by one.`
3528
+ content: `STOP: Per-turn read limit (${READ_ONLY_PER_TURN_CAP}). Use search_files or exec with grep instead of reading files one by one.`,
3283
3529
  });
3284
3530
  }
3285
3531
  if (cfg.verbose) {
@@ -3293,8 +3539,7 @@ export async function createSession(opts) {
3293
3539
  // Models that support parallel calls: read-only in parallel, mutations sequential
3294
3540
  const readonly = toolCallsArr.filter((tc) => isReadOnlyToolDynamic(tc.function.name));
3295
3541
  const others = toolCallsArr.filter((tc) => !isReadOnlyToolDynamic(tc.function.name));
3296
- const ro = await Promise.all(readonly.map((tc) => runOne(tc)
3297
- .catch((e) => catchToolError(e, tc))));
3542
+ const ro = await Promise.all(readonly.map((tc) => runOne(tc).catch((e) => catchToolError(e, tc))));
3298
3543
  results.push(...ro);
3299
3544
  for (const tc of others) {
3300
3545
  if (ac.signal.aborted)
@@ -3352,7 +3597,7 @@ export async function createSession(opts) {
3352
3597
  if (readOnlyExecTurnHints.length) {
3353
3598
  const previews = readOnlyExecTurnHints
3354
3599
  .slice(0, 2)
3355
- .map((cmd) => cmd.length > 140 ? `${cmd.slice(0, 140)}…` : cmd)
3600
+ .map((cmd) => (cmd.length > 140 ? `${cmd.slice(0, 140)}…` : cmd))
3356
3601
  .join(' | ');
3357
3602
  messages.push({
3358
3603
  role: 'user',
@@ -3393,7 +3638,9 @@ export async function createSession(opts) {
3393
3638
  }
3394
3639
  // ── Escalating cumulative read budget (§ anti-scan guardrails) ──
3395
3640
  // Warn zone: append warnings to each read result when approaching the hard cap
3396
- if (!readBudgetWarned && cumulativeReadOnlyCalls > READ_BUDGET_WARN && cumulativeReadOnlyCalls <= READ_BUDGET_HARD) {
3641
+ if (!readBudgetWarned &&
3642
+ cumulativeReadOnlyCalls > READ_BUDGET_WARN &&
3643
+ cumulativeReadOnlyCalls <= READ_BUDGET_HARD) {
3397
3644
  readBudgetWarned = true;
3398
3645
  messages.push({
3399
3646
  role: 'user',
@@ -3433,7 +3680,7 @@ export async function createSession(opts) {
3433
3680
  messages.push({ role: 'assistant', content: visible || content || '' });
3434
3681
  messages.push({
3435
3682
  role: 'user',
3436
- content: '[system] MCP tools are now enabled for this task. Continue and call tools as needed.'
3683
+ content: '[system] MCP tools are now enabled for this task. Continue and call tools as needed.',
3437
3684
  });
3438
3685
  continue;
3439
3686
  }
@@ -3452,7 +3699,7 @@ export async function createSession(opts) {
3452
3699
  const clippedReminder = reminder.length > 1600 ? `${reminder.slice(0, 1600)}\n[truncated]` : reminder;
3453
3700
  messages.push({
3454
3701
  role: 'user',
3455
- content: `[system] Stuck narrating. Resume with tools.\nTask:\n${clippedReminder}`
3702
+ content: `[system] Stuck narrating. Resume with tools.\nTask:\n${clippedReminder}`,
3456
3703
  });
3457
3704
  await emitTurnEnd({
3458
3705
  turn: turns,
@@ -3474,7 +3721,7 @@ export async function createSession(opts) {
3474
3721
  noToolNudgeUsed = true;
3475
3722
  messages.push({
3476
3723
  role: 'user',
3477
- content: '[system] Use tools now or give final answer.'
3724
+ content: '[system] Use tools now or give final answer.',
3478
3725
  });
3479
3726
  await emitTurnEnd({
3480
3727
  turn: turns,
@@ -3584,11 +3831,21 @@ export async function createSession(opts) {
3584
3831
  };
3585
3832
  // expose via getters so setModel() / reset() don't break references
3586
3833
  return {
3587
- get model() { return model; },
3588
- get harness() { return harness.id; },
3589
- get endpoint() { return cfg.endpoint; },
3590
- get contextWindow() { return contextWindow; },
3591
- get supportsVision() { return supportsVision; },
3834
+ get model() {
3835
+ return model;
3836
+ },
3837
+ get harness() {
3838
+ return harness.id;
3839
+ },
3840
+ get endpoint() {
3841
+ return cfg.endpoint;
3842
+ },
3843
+ get contextWindow() {
3844
+ return contextWindow;
3845
+ },
3846
+ get supportsVision() {
3847
+ return supportsVision;
3848
+ },
3592
3849
  get messages() {
3593
3850
  return messages;
3594
3851
  },
@@ -3647,7 +3904,7 @@ export async function createSession(opts) {
3647
3904
  },
3648
3905
  executePlanStep,
3649
3906
  clearPlan,
3650
- compactHistory
3907
+ compactHistory,
3651
3908
  };
3652
3909
  }
3653
3910
  export async function runAgent(opts) {
@@ -3656,7 +3913,7 @@ export async function runAgent(opts) {
3656
3913
  apiKey: opts.apiKey,
3657
3914
  confirm: opts.confirm,
3658
3915
  confirmProvider: opts.confirmProvider,
3659
- runtime: opts.runtime
3916
+ runtime: opts.runtime,
3660
3917
  });
3661
3918
  return session.ask(opts.instruction, opts.onToken);
3662
3919
  }